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 auser.deletion_requestedaudit 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
nullfor an unknown user.
Each job goes through these phases, claimed with a lease and fenced by attempt_count:
- 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.
- 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.deletedand 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