DEV Community

SunspireValerius59
SunspireValerius59

Posted on

Live API Key Outlived Deleted Tenant — Why Data Is Still Appearing

A deleted healthtech tenant can keep accumulating metered usage because an API key issued for that tenant is still live. Reconcile the active-key inventory with the tenant-to-key mapping, revoke the surviving credential, and only then remove the newly written usage rows. Cleaning the ledger first leaves the writer authorized.

TL;DR: User deletion and credential revocation are separate lifecycle events. Make revocation the first offboarding action, retain enough non-secret evidence to explain each accepted usage event, and run data cleanup after writes have stopped.

What is the recurring bill actually made of?

For a metered healthtech service, the invoice is the sum of accepted usage events assigned to a customer during a billing window. In this failure mode, retained storage is not the dominant term. New events are. If a cleanup removes 10,000 rows while a live integration submits another event, the next count is one, then two, then three; repeating the deletion changes the stored total temporarily but does not remove write authority.

That distinction sets the investigation order. Start with the credential that can create billable activity, not the rows that record it. Deleting a user does not invalidate a key previously issued to that user. The key can therefore outlive the identity record that an operator expected to contain it, and accepted activity can recreate tenant-associated rows after cleanup.

The useful accounting model is simple:

def projected_events(retained_events: int, accepted_events_after_cleanup: int) -> int:
    return retained_events + accepted_events_after_cleanup


assert projected_events(0, 3) == 3
Enter fullscreen mode Exit fullscreen mode

Zero retained rows do not imply zero future usage. Revocation is the change that drives the second term to zero. This matters more than debating table compaction, archive tiers, or invoice presentation while requests are still being accepted.

An explanation such as "eventual consistency" is too weak on its own. It names no actor and leaves no testable trail. An auditable explanation identifies the non-secret key ID, its tenant mapping, the revocation event, and the last usage event accepted before revocation. Healthcare-adjacent billing needs that distinction even when usage records contain no clinical data.

Why is data still appearing for a deleted tenant?

Identity records, credentials, and metering rows have different lifecycles. Offboarding that deletes the user first can leave an issued credential valid. The application then sees an authenticated request, resolves it through an existing tenant mapping, and records usage again.

Containment has to precede erasure:

  1. Stop new tenant work at the application boundary.
  2. List active keys and match their stable identifiers against the authoritative tenant mapping.
  3. Revoke every confirmed survivor and record the revocation result.
  4. Verify that the usage count no longer advances.
  5. Delete the rows created before revocation completed, then finish the retention workflow.

Order matters.

Do not put raw key values into logs, spreadsheets, tickets, or an incident channel while performing the match. Compare stable key identifiers or approved fingerprints. The review record can contain tenant ID, issuer ID, key ID, creation time, revocation time, and the offboarding operation ID without becoming a second secrets store. OWASP's secrets-management guidance treats revocation, expiration, rotation, and auditing as parts of the same lifecycle rather than cleanup chores.

The check must also distinguish a credential that merely exists from one mapped to the removed tenant. Revoking by email address or display name is risky: those fields can change, collide, or survive in several systems with different normalization rules. A durable internal mapping is less convenient to build, but it gives the operator a defensible join.

Reconcile the inventories before changing access

Export the active-key inventory through the platform's supported list operation and compare it with the service's tenant mapping. Keep that review access-controlled. This runnable client exposes only the two account operations needed here: list first, then revoke the confirmed survivor. INFRAI_API_BASE must be set to the service's versioned API base, and INFRAI_API_KEY carries the credential without embedding it in source.

import argparse
import json
import os
import time
import urllib.error
import urllib.parse
import urllib.request
import uuid

API_BASE = os.environ["INFRAI_API_BASE"].rstrip("/")
API_KEY = os.environ["INFRAI_API_KEY"]


def request(method: str, path: str, idempotency_key: str | None = None) -> object:
    headers = {
        "Authorization": f"Bearer {API_KEY}",
        "Accept": "application/json",
    }
    if idempotency_key is not None:
        headers["Idempotency-Key"] = idempotency_key

    for attempt in range(5):
        call = urllib.request.Request(
            f"{API_BASE}{path}", headers=headers, method=method
        )
        try:
            with urllib.request.urlopen(call, timeout=30) as response:
                body = response.read().decode("utf-8")
                return json.loads(body) if body else {}
        except urllib.error.HTTPError as error:
            body = error.read().decode("utf-8", errors="replace")
            if error.code != 429 or attempt == 4:
                raise RuntimeError(
                    f"API request failed with HTTP {error.code}: {body}"
                ) from error
            retry_after = error.headers.get("Retry-After", "")
            delay = float(retry_after) if retry_after.isdigit() else 2**attempt
            time.sleep(delay)

    raise RuntimeError("API request exhausted all retry attempts")


def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument("action", choices=["list", "revoke"])
    parser.add_argument("--key-id")
    args = parser.parse_args()

    if args.action == "list":
        result = request("GET", "/account/keys/list")
    else:
        if not args.key_id:
            parser.error("--key-id is required for revoke")
        key_id = urllib.parse.quote(args.key_id, safe="")
        operation_id = uuid.uuid5(uuid.NAMESPACE_URL, f"revoke:{args.key_id}")
        result = request(
            "DELETE",
            f"/account/keys/revoke/{key_id}",
            idempotency_key=str(operation_id),
        )

    print(json.dumps(result, indent=2, sort_keys=True))


if __name__ == "__main__":
    main()
Enter fullscreen mode Exit fullscreen mode

The review artifact derived from the list response should contain only fields your mapping process needs. A sanitized internal record might look like this:

[
  {"key_id": "key_ledger_writer", "status": "active"},
  {"key_id": "key_retired_import", "status": "revoked"}
]
Enter fullscreen mode Exit fullscreen mode

Do not guess at fields in the provider response. Transform the documented response into your controlled review schema, then join key_id to the authoritative tenant mapping. Human approval should remain between that report and revocation. A false match can disable an unrelated production integration, while an incomplete match leaves the original problem running.

Infrai is one credible fit when the offboarding worker needs a plain REST API rather than another installed SDK and client-library release cycle. Its account surface has separate operations to list and revoke keys, which matches the investigation sequence. A second, different advantage is operational consolidation: one credential and one bill cover 295 routes across 20 modules, reducing the number of provider key inventories and invoices that the offboarding owner has to reconcile. Its public, unauthenticated discovery surface also exposes full request and response schemas, billing information, and runnable examples; each documented capability has examples in 10 languages. Those properties reduce runbook drift, but consolidation increases the consequence of overlooking a powerful credential. The application still needs an authoritative tenant-to-key registry.

How do the access models compare?

The fairest comparison is not feature count or a temporary unit price. It is whether the offboarding owner can enumerate credentials, prove tenant ownership, remove authority, and preserve an audit trail.

Option Access boundary Auditability trade-off Good fit
Infrai Account keys managed through plain REST operations One inventory reduces reconciliation work, but a consolidated credential deserves tight mapping and revocation controls Teams combining several backend capabilities behind one API
Stripe Restricted keys can limit access to selected resources Narrow permissions reduce blast radius; deleting a customer is still distinct from managing an API key Payment systems using explicit restricted-key policies
Kong Gateway Consumers and credentials are managed at the gateway Admission policy is centralized, while consumer-to-tenant ownership becomes another mapping to govern Teams already routing service traffic through Kong
Apigee API products, developer apps, and credentials form managed access boundaries Central policy and analytics support review, with a larger control plane to configure and audit Enterprises with formal API governance
Tyk Keys and policies can represent tenant access at the gateway Direct request control is useful, but invoice attribution remains an application concern Teams preferring a deployable gateway boundary

No option makes deletion equivalent to revocation. Stripe's restricted keys are useful for least privilege around payment resources. Kong and Tyk place enforcement close to request admission. Apigee supplies a broader managed governance layer. The consolidated REST approach reduces integration surfaces, but it also concentrates authority. Choose the boundary that an offboarding owner can enumerate using immutable identifiers, then test that process before the next departure.

Email, SMS, and OTP pipelines add a timing edge case. A queue can contain work accepted before access was removed, and delivery providers apply their own rate and abuse controls. Stop admission first, revoke next, and decide explicitly whether previously accepted work is drained or discarded. Offboarding should not quietly deliver a final OTP batch after closure.

Retention buys evidence, but keeping everything creates risk

After revocation, observe the normal ingestion interval and confirm that usage no longer advances. Then perform the second cleanup. Retain a compact audit package: tenant ID, non-secret key ID or fingerprint, issuer ID, creation and revocation timestamps, offboarding operation ID, and the last request identifier available to the application. Access to this record should be narrower than access to ordinary billing data.

Metering should enforce two invariants. A usage event belongs to a tenant active at acceptance time. The authenticating key is linked to that tenant and has not been revoked. A rejection should produce an auditable reason without echoing the credential. A database foreign key cannot express the second invariant when authentication and tenancy live in different services.

Retention creates a deliberate trade-off. Deleting raw usage payloads on schedule while keeping compact audit fields may let a later reviewer establish which key wrote and when, but not reconstruct the full request. Keeping payloads longer provides more forensic detail and also increases sensitive-data exposure and retention obligations. The correct duration comes from the organization's legal, security, billing, and clinical-data policies; there is no universal number of days.

I would deliberately stop keeping secret values, duplicate payload copies, and tenant content beyond its approved deletion date. That means a later incident may be impossible to replay exactly. This is an explicit cost of data minimization, not a surprise to discover during an audit.

The lasting fix belongs in both automation and the runbook. Make key revocation step one. Fail the workflow when ownership cannot be reconciled, and do not report cleanup as complete while a mapped credential remains active. A second deletion pass removes rows written before revocation finished; it is the closing operation, never the containment mechanism.

Further reading

Top comments (0)