Short answer: revoke tenant API keys before deleting user data, but first close admission for new work and preserve the minimum immutable billing evidence needed to attribute late logistics charges.
That order separates three concerns that are often collapsed into one dangerous "delete account" button: stopping new customer-authorized activity, finishing the accounting for activity already accepted, and erasing personal data. In a logistics SaaS with a prepaid balance, confusing those boundaries can leave unattended shipments consuming funds or leave a finance team unable to explain which tenant funded a carrier charge.
Start with the decision table. The deciding axis is attribution accuracy, not which cleanup call is easiest to run first.
| Exit strategy | Pick this when | Attribution consequence | Main limitation |
|---|---|---|---|
| Immediate credential revocation, then controlled drain | New tenant work must stop now, while accepted jobs may still settle | Strong when every accepted job already has a tenant and billing correlation ID | Requires an internal identity for the drain path |
| Admission freeze, short grace window, then revocation | Mobile scanners or depots need a defined final synchronization window | Accurate only if the cutoff is explicit and every request carries its acceptance time | Extends credential exposure during the grace window |
| Legal hold with operational access disabled | Erasure is paused by a documented retention obligation | Keeps billing evidence separate from interactive access | Not suitable as a default retention policy |
The first strategy is the practical default. Fast revocation matters, but the real mechanism is a durable cutoff plus a ledger boundary. Revocation alone is just a security event.
How should a Node.js SaaS revoke API keys during tenant offboarding?
Treat offboarding as a three-phase control plane: freeze, reconcile, erase. First, atomically move the tenant from active to frozen and record acceptedThrough. The request gateway rejects new tenant-authenticated work after that commit. Second, revoke every tenant credential and let already accepted jobs finish under an internal worker identity. Third, reconcile the prepaid ledger, apply the retention decision, and erase user data.
Order is everything.
The diagram in words is short: tenant key to admission gate; admission gate to durable job; durable job to carrier operation; carrier result to billing ledger. The tenant ID and a stable operation ID cross the first two arrows. Personal profile fields do not need to cross them. During exit, the admission gate closes, the key stops working, and the worker continues only from durable jobs that were accepted before the recorded cutoff.
Why not delete first? Credential records may depend on the tenant row, audit evidence can lose its subject, and a delayed webhook or queue consumer may no longer have enough context to debit the right prepaid balance. Why not revoke first with no freeze transaction? Two requests racing the revocation can land on opposite sides of an ambiguous boundary. A single committed state transition gives operations and finance one timestamp to inspect.
Keep the boundary mechanical. A request accepted at or before the cutoff owns its original tenant attribution even if settlement happens later. A request arriving after the cutoff is rejected and creates no billable operation. Clock time is useful for investigation, but the database commit and operation ID should decide the case; clocks on gateways and workers can disagree.
Pick the exit mode that matches the work already in flight
Immediate revocation plus a controlled drain fits server-to-server logistics integrations where jobs are durably accepted before execution. It is especially useful when a label purchase, route optimization, or pickup request can settle after the caller has gone away. The customer credential should not be kept alive merely so an internal worker can finish platform-owned work.
A grace window fits a narrower case: a depot has intermittently connected scanners and the contract defines a final sync interval. The catch is that "grace" must be an admission rule, not a polite email. Record its deadline, restrict which operations remain admissible, alert on usage, and revoke at the deadline. This choice is not suitable when termination follows suspected credential compromise; there, close admission and revoke immediately.
A legal hold is a data disposition state, not an authentication state. Interactive access still ends. The hold changes which records enter the erasure queue and why. It should never silently convert a deleted user profile into an indefinite shadow account.
There isn't one universal retention duration. Contracts, tax rules, privacy obligations, dispute windows, and shipment settlement behavior differ by jurisdiction and business model. I'm not sure a generic number would survive contact with any specific logistics operation; counsel and the data owner must supply the policy, while engineering makes that policy explicit, testable, and attributable.
Build the 3-phase transition as an idempotent TypeScript workflow
The useful abstraction is a resumable state machine, not a controller that calls five services and hopes they all return before the process exits. Each transition writes durable evidence. Each side effect has an idempotency key. A retry reads the stored phase and continues without reopening admission or duplicating a debit.
This example deliberately uses interfaces rather than a vendor SDK or invented network route. The stores must implement atomic compare-and-set semantics in the chosen database, and the outbox must publish only after its transaction commits.
type ExitPhase = "active" | "frozen" | "reconciling" | "erasing" | "closed";
type ExitRecord = {
tenantId: string;
phase: ExitPhase;
acceptedThrough?: string;
exitId: string;
};
interface ExitStore {
freeze(tenantId: string, exitId: string): Promise<ExitRecord>;
advance(exitId: string, from: ExitPhase, to: ExitPhase): Promise<ExitRecord>;
get(exitId: string): Promise<ExitRecord>;
}
interface CredentialStore {
revokeTenantKeys(tenantId: string, reason: "tenant_exit"): Promise<void>;
}
interface WorkStore {
countUnsettled(tenantId: string, acceptedThrough: string): Promise<number>;
}
interface BillingStore {
sealAttribution(tenantId: string, exitId: string): Promise<void>;
}
interface ErasureQueue {
enqueue(input: { tenantId: string; exitId: string }): Promise<void>;
}
type Dependencies = {
exits: ExitStore;
credentials: CredentialStore;
work: WorkStore;
billing: BillingStore;
erasure: ErasureQueue;
};
export async function advanceTenantExit(
deps: Dependencies,
tenantId: string,
exitId: string,
): Promise<ExitRecord> {
let record = await deps.exits.get(exitId);
if (record.phase === "active") {
record = await deps.exits.freeze(tenantId, exitId);
}
if (record.phase === "frozen") {
await deps.credentials.revokeTenantKeys(tenantId, "tenant_exit");
record = await deps.exits.advance(exitId, "frozen", "reconciling");
}
if (record.phase === "reconciling") {
const unsettled = await deps.work.countUnsettled(
tenantId,
record.acceptedThrough!,
);
if (unsettled > 0) return record;
await deps.billing.sealAttribution(tenantId, exitId);
await deps.erasure.enqueue({ tenantId, exitId });
record = await deps.exits.advance(exitId, "reconciling", "erasing");
}
return record;
}
There is a subtle but important split here. revokeTenantKeys can be repeated because revoking an already revoked key should preserve the desired state. sealAttribution needs an exitId so a retry cannot create a second finalization. The erasure consumer uses the same ID. Don't generate a fresh one inside a retry.
The handler also refuses to erase while accepted operations remain unsettled. That does not mean retaining every user field. Build a billing projection containing only what the reconciliation rule needs: pseudonymous tenant ledger ID, operation ID, acceptance boundary, amount, currency, debit or credit direction, and settlement status. Keep shipment addresses, contact names, and raw credentials out of that projection unless a documented policy specifically requires them.
Walk one parcel through the cutoff to see why the extra state matters. Imagine operation op-1842 enters the admission transaction just before the tenant is frozen. That transaction stamps the tenant ledger ID and cutoff-valid acceptance record onto the durable job, then commits. A second request, op-1843, reaches the gate after the freeze commit and is rejected before it can reserve prepaid funds. The revocation worker now disables three credentials used by the warehouse, dispatch service, and reporting integration. Hours later, the carrier result for op-1842 arrives. The settlement worker attributes it with the durable operation ID, not with an API key lookup and not with a user profile that may be queued for erasure. If the result is delivered twice, the ledger's idempotency constraint leaves one mutation. Finance can therefore explain why one charge belongs to the closing tenant while the rejected request does not. This is a hypothetical walkthrough, not a benchmark, but it exposes the exact assertions an integration test needs: one committed acceptance, one rejection, one debit, zero restored credentials, and no dependency on personal data during settlement.
What happens if a worker finds unsettled > 0 for hours? It stays in reconciling; it does not restore the customer key. An alert should point to the operation IDs blocking closure. This is where the crisp state names pay off: support can distinguish "access ended" from "accounting still draining" without reading application logs line by line.
Observe the boundary, then test the ugly races
Logs should answer four questions with structured fields: which tenant exit changed phase, which exit ID caused it, what acceptance cutoff governed the decision, and how many unsettled operations remain. Never log API-key material. OWASP's secrets guidance recommends lifecycle controls around creation, rotation, revocation, and expiration, while also warning that secrets should not be logged.
Metrics should describe the mechanism rather than the customer. Useful counters include exit transitions by source and destination phase, post-freeze admission rejections, credential revocation completions, and erasure completions. A gauge for exits stuck in reconciling, paired with the age of the oldest one, catches the case that threatens an unattended prepaid balance: work keeps settling while nobody can see why the account has not closed.
Trace context can connect admission to settlement, but tenant identity in trace baggage deserves care because propagated fields can travel farther than expected. Prefer an opaque billing correlation ID. Node.js AsyncLocalStorage can carry that ID through an asynchronous request path, yet the durable job payload must also contain it; process-local context disappears at a queue boundary.
Test the races with controlled barriers, not timing guesses. Pause one request immediately before the freeze commit, submit another immediately after it, and assert that exactly one can become accepted work. Repeat credential revocation twice. Deliver the same settlement event twice and expect one ledger mutation. Run the erasure consumer twice and expect the same terminal state. Then inject a 409 from the compare-and-set operation to prove that a concurrent runner rereads state instead of skipping forward.
Alert on broken invariants: accepted work after acceptedThrough; a closed exit with unsettled operations; an active credential attached to a tenant in erasing; or two ledger rows for one operation ID. Those alerts are better than a generic "offboarding failed" page because they say which promise was broken.
One short rule helps during review: trace IDs explain execution; ledger IDs prove attribution.
Limits and the final operating rule
This design is not suitable when downstream actions cannot be given durable operation IDs or when the platform cannot atomically freeze admission. Fix those foundations before automating erasure. Stick with a manually supervised exit for rare, irreversible physical operations whose completion cannot be observed reliably; automation should not pretend uncertainty has vanished.
The controlled drain also adds state, storage, dashboards, and an on-call obligation. A very small SaaS with no asynchronous work may reasonably freeze, revoke, verify zero in-flight requests, and erase in one supervised run. Once logistics jobs outlive HTTP requests, though, the explicit phases earn their keep.
Close admission first. Revoke tenant keys next. Reconcile previously accepted work under internal authority, seal the minimal billing record, and only then erase user data according to policy. That sequence keeps a prepaid balance explainable without keeping customer access alive.
Top comments (0)