Short answer: freeze the profile first, revoke every active session next, and schedule deletion only after a verifiable retention checkpoint. This sequence keeps a B2B SaaS account from creating new work while giving operators a reversible state for reconciliation and an auditable path to erasure.
Shutdown is a state machine, not a delete button
An email-and-password account has more live state than its profile row. Browser cookies, refresh tokens, password-reset links, API keys, queued jobs, and cached authorization decisions can all outlive a user click. A hard delete addresses one row while those capabilities continue to act.
I model closure as explicit transitions: active -> frozen -> revoked -> pending_erasure -> erased. Each transition gets an immutable event with the actor, reason, request id, and timestamp. The profile's state is the admission gate; session revocation is the capability cut; erasure is the irreversible retention action. Keeping those concerns separate makes retries safe because the same command can be replayed without inventing a second shutdown, even when an outbox worker is restarted halfway through a batch and the database connection is recycled between attempts.
The first implementation I reviewed treated a successful DELETE /users/{id} response as completion. A worker then accepted a password-reset token issued minutes earlier. The bug was conceptual, not syntactic: deletion had been mistaken for revocation. It failed quietly.
I now require a read-after-write check that proves the account is frozen before any asynchronous cleanup is enqueued.
What should Node.js teams revoke before profile state changes?
The order should follow the attacker's remaining options. First mark the profile frozen in the primary database, with a monotonic version. Authentication and authorization handlers reject new sign-ins and compare that version against the session's issue version. Next revoke sessions and reset credentials: delete or invalidate refresh-token families, rotate password-reset secrets, and disable API keys. Finally drain account-scoped jobs and caches.
A single source of truth helps, but it does not make distributed state synchronous. In Node.js, an outbox row written in the same transaction as the freeze event lets workers publish account.frozen and sessions.revoked with at-least-once delivery. Consumers must be idempotent. A duplicate event should produce the same final version, not a second email, refund, or deletion request.
type Shutdown struct {
AccountID string
Version int64
Reason string
}
func ApplyFreeze(s Shutdown, currentVersion int64, currentState string) (int64, string, error) {
if s.Version < currentVersion {
return currentVersion, currentState, nil
}
if currentState == "erased" {
return currentVersion, currentState, nil
}
return s.Version, "frozen", nil
}
That small monotonic rule is useful in tests and in production replay. A stale worker cannot move an erased account backward, and a retried freeze is harmless.
When do profile state, session revocation, and eventual deletion fit?
Use a frozen state when support, fraud, or a customer administrator may need to reverse the action. Use immediate revocation when credentials may be exposed; waiting for the deletion job is an unnecessary window. Use eventual deletion when legal holds, invoices, chargebacks, or tenant-level retention rules require records to remain for a defined period.
The trade-off is concrete:
| Choice | Protects against | Costs or limits |
|---|---|---|
| Freeze profile | New sign-ins and writes | Existing tokens need a separate revocation step |
| Revoke sessions | Reuse of active credentials | Users must authenticate again if closure is reversed |
| Eventual deletion | Retention and recovery requirements | Personal data remains in a controlled tombstone period |
Do not promise a universal deletion deadline. A finance tenant may have an invoice retention obligation that a trial workspace does not, and a legal hold can supersede a normal erasure request. The policy should name the data classes, clock start, hold behavior, and evidence kept after erasure.
Every worker should record an outcome per account and transition, including already_applied, waiting_on_hold, and completed. Metrics should distinguish a frozen profile with live sessions from a fully revoked account; one aggregate success counter hides the exact risk you need to investigate. Alert on age of the oldest pending transition, not merely on queue depth.
The catch is operational cost: tombstones, audit events, and replayable outbox records consume storage and require access controls of their own. This design is unsuitable when the product has no durable identity boundary or cannot honor a retention policy; in that case, keep authentication in a system that can provide transactionality and documented revocation semantics. Stick with a simpler disabled flag only for low-risk internal tools where sessions are short-lived and there is no regulated data.
I am not sure a single timeout can represent every tenant's legal requirement. Your mileage may vary by jurisdiction, so have counsel turn the retention matrix into configuration, then test the configuration as code. The engineering contract remains stable: no new capability after freeze, no accepted credential after revocation, and no erasure without evidence that the retention gate is clear.
References
- OWASP Authentication Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html
- OWASP Session Management Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html
- RFC 7009, OAuth 2.0 Token Revocation: https://www.rfc-editor.org/rfc/rfc7009
- NIST SP 800-63B, Digital Identity Guidelines: https://pages.nist.gov/800-63-3/sp800-63b.html
Top comments (0)