Data Retention
Several AuthHero tables hold rows that are only useful for a short window — a code that expires in minutes, a session that expires in weeks, an outbox event that has already been delivered. Nothing deletes these rows as a side effect of normal traffic, so a deployment without a scheduled sweep accumulates them forever.
Retention is one scheduled call: runRetention. Schedule it and you are done — including for prunable tables added in future AuthHero versions, which it picks up without any change to your handler.
Schedule this
import { runRetention } from "authhero";
export default {
async scheduled(_event: ScheduledEvent, env: Env) {
const { sweeps } = await runRetention({ dataAdapter });
console.log(sweeps);
},
};Daily is a reasonable cadence.
Scaffolded projects already have this
If you started from a create-authhero template you do not need to add this by hand — it is wired at scaffold time:
cloudflareandcloudflare-control-planeship ascheduledhandler that callsrunRetention, plus a[triggers] crons = ["0 3 * * *"]block inwrangler.toml. Adjust the schedule to taste.cloudflare-wfp-tenanthas no cron on purpose. Workers uploaded to a Workers-for-Platforms dispatch namespace never receivescheduledevents, so a cron there would silently never fire. Each tenant Worker reads only its own D1, so those shards must be swept centrally: iterate tenants from the control plane and callrunRetention({ dataAdapter, tenantId })against each tenant's database. (The control-plane template sweeps its own D1 only; the cross-tenant driver is left for you to wire.)aws-sstneeds no sweep at all. DynamoDB's native TTL expires codes, sessions and refresh tokens byexpiresAton its own, and the AWS adapter exposes nooutboxorsessionCleanup. See Adapter behaviour.
runRetention sweeps every prunable table, using the default window for each:
| Table(s) | Default window | Notes |
|---|---|---|
codes | 1 day past expiry | Authorization codes, OTPs, OAuth2 state, email-verification and password-reset codes. Highest-churn table in the system. |
outbox_events | 7 days after processing | Only processed and dead-lettered events are removed. Skipped when the adapter has no outbox. |
action_executions | 30 days after creation | One row per action execution — diagnostic history referenced from logs. Skipped when the adapter's backend expires rows itself (DynamoDB TTL, Analytics Engine). |
sessions, refresh_tokens, login_sessions | 1 week past expiry (fixed grace period) | Skipped when the adapter does not implement sessionCleanup. |
Codes, outbox events and action executions are swept globally; they are not tenant-scoped, because an expired row is dead regardless of who owns it. Pass tenantId to scope the session sweep to one tenant.
Override the windows per table when you need to:
await runRetention({
dataAdapter,
codesRetentionDays: 1,
outboxRetentionDays: 30,
actionExecutionsRetentionDays: 90,
});What it returns, and what it throws
runRetention resolves to { sweeps } — one entry per table with a swept / skipped / failed status and, where the adapter reports one, a deleted count. Logging it gives you a retention record per run.
Every sweep runs even if an earlier one throws, so a single broken adapter method cannot stop the other tables being pruned. If any sweep failed, a RetentionSweepError is thrown once at the end, carrying .result (the partial sweep list) and .errors.
What it does not cover
Outbox delivery. runRetention cleans up outbox_events but does not drain them. Delivery is runOutboxRelay's job and is scheduled separately. Running both is safe — the relay's own cleanup pass and this one are idempotent.
logs. Audit-retention obligations differ per deployment, so AuthHero will not silently delete audit rows on your behalf. Prune logs on date yourself, on whatever window your obligations require.
Per-table escape hatches
The individual helpers remain exported for consumers who want to sweep a single table, or run tables on different schedules:
import { cleanupCodes, cleanupOutbox, cleanupActionExecutions } from "authhero";
await cleanupCodes(dataAdapter.codes, { retentionDays: 1 });
await cleanupOutbox(dataAdapter.outbox, { retentionDays: 7 });
await cleanupActionExecutions(dataAdapter.actionExecutions, {
retentionDays: 30,
});
await dataAdapter.sessionCleanup?.();runRetention is the recommended default; reach for these only when you have a reason.
Why codes has a grace period
codes rows are dead the moment they pass expires_at, so the default one-day window is not a retention policy so much as a safety margin against clock skew — and it keeps a recently expired code around long enough to debug a failed login. Setting retentionDays: 0 is valid and sweeps everything already expired.
Adapter behaviour
- Drizzle sweeps
codesusing the indexed ISO-8601expires_atcolumn directly. - Kysely sweeps using
expires_at_ts, a numeric twin ofexpires_atadded by the2026-07-16T12:00:00_codes_expires_at_tsmigration. That migration also prunes whatever had already accumulated, so the first deploy after upgrading clears the backlog. - DynamoDB (AWS) needs no sweep: codes carry a native
ttlattribute and DynamoDB expires them itself.cleanup()is a no-op returning0, so the handler above is safe to run unchanged. action_executionsis swept on the numericcreated_at_tscolumn in both drizzle and kysely, indexed by the0004_action_executions_created_at_index/2026-08-10T12:00:00_action_executions_created_at_indexmigrations. Unlike the codes migration these do not prune — how much execution history to keep is your policy call, so the backlog is cleared by the first scheduled sweep instead (in bounded chunks, safe at any size). Adapters whose backend expires rows itself (Analytics Engine, DynamoDB) simply omitcleanupand the sweep reportsskipped.
If you are upgrading an existing deployment
The kysely migration prunes expired codes before it adds the index, so it stays cheap even on a table that has grown to millions of rows. It logs what it removed. Once it has run, the scheduled runRetention call is what stops the table growing again — the migration is a one-time catch-up, not a substitute for the cron. A deployment that runs the migration but never schedules the sweep will regrow the table, with the one-time prune masking it for weeks.