AUTH4D-27 retention and deferred data deletion

Retention and deletion run in the API Worker’s scheduled handler (packages/lifecycle). There is no public HTTP surface. Management endpoints that accept deletion requests call requestUserDeletion from @auth4/lifecycle.

This policy limits how long authentication data is stored. It does not claim compliance with any particular regulation.

Retention policy

Data Removed when
Audit events occurred_at is older than the audit retention period (default 30 days)
Encrypted email payload (email_outbox) Delivery is sent, failed or cancelled, or the payload has expired; only the payload column is cleared
Redacted email delivery-status row Payload already removed and the row is older than the audit retention period
Session index (session_indexes) Revoked for more than 1 day, or older than the longest session lifetime (30 days)
Inactive guest No activity for the guest retention period (default 30 days); see below
Disabled provisional user left by a guest upgrade Disabled, owns no identity, membership or session index, and the safety window has passed
Finished deletion job record completed/cancelled and older than the audit retention period
Durable Object challenges, codes, sessions and families Their own expiry, enforced by Durable Object alarms

Refresh rotation does not update the D1 session index. Browser idle or absolute expiry therefore does not prove a session is dead, and an index row is purged only once even a refresh-backed session would have expired. Before a row is purged, the latest activity it could represent is saved in lifecycle_user_activity.

Optional Worker variables LIFECYCLE_AUDIT_RETENTION_DAYS and LIFECYCLE_GUEST_RETENTION_DAYS override the defaults. If a value is invalid, the run fails and nothing is deleted. The access-token safety window (default 360 seconds: 5-minute access tokens plus 60 seconds of clock skew) and the maximum session lifetime can be made longer, never shorter.

User deletion

requestUserDeletion({ db, now }, { tenantId, userId, requestId? }):

  • accepts customer tenants only; the control-plane realm is rejected;
  • disables the user immediately, which blocks new sign-ins, refreshes and guest upgrades;
  • queues one deletion job per tenant user (deletion_jobs_one_open_per_user), appends a user.deletion_requested audit event and returns { job, created };
  • is idempotent: if a job is already open it returns that job, after deletion it returns the completed job, and it returns null for an unknown user.

Each job goes through these phases, claimed with a lease and fenced by attempt_count:

  1. revoke: keep the user disabled, revoke every authoritative session and refresh family in the session Durable Object, mark D1 session indexes and consent revoked, then wait for the access-token safety window.
  2. purge: revoke again. If a sign-in raced the disable, the safety window restarts. Otherwise one D1 transaction deletes the user’s session indexes, consent, memberships, identities, activity record and user row, appends user.deleted and completes the job.

If the session authority is unavailable, or any step fails, the job is requeued with exponential backoff (30 seconds up to 1 hour) and a non-sensitive error_code. After 8 attempts it is parked as failed for an operator. If a worker stops mid-job, its lease expires and another worker resumes the job. Every step is idempotent, and a job never re-enables a user or recreates credentials.

What is kept after deletion: the job row (tenant ID, job ID, request ID, pseudonymous user ID, reason, timestamps) and audit events that reference the user ID. Neither contains an email address or credential, and both are removed by audit retention. Revoked Durable Object session records remain until their own expiry.

Inactive guests

A guest is a user whose only identities are guest. It is expired only when all of the following hold: it was created before the cutoff, it has no non-guest identity and no membership, no session index was seen or revoked after the cutoff, no unrevoked session index could still be refreshing, no recorded activity falls after the cutoff, and no deletion job is open or failed.

Every upgrade holds an active guest session, and the upgrade transaction only moves a credential onto a guest that is not disabled. The guest check, job insert and disable run in one D1 transaction. Either the upgrade lands first and the guest is kept, or the guest is disabled first and the upgrade is refused. Expired guests receive a guest.expired audit event and then go through the same deletion job phases.

Bounded processing

Each run handles at most 25 tenants per task, 200 rows per tenant, 1,000 rows per retention task, 100 guest checks and 10 deletion jobs. Per-task cursors in lifecycle_cursors save the tenant and the key within it. When a batch is full, the cursor stays on that tenant. A cursor is saved only after its work is done, so an interrupted run repeats idempotent work rather than skipping tenants. Tasks run independently. If any fail, the scheduled invocation throws LifecycleRunError naming only the failed steps.

Source: docs/api-spec/auth4d-27.md