Agora

Compliance (LGPD / GDPR)

Account deletion with cascade, data export, verified-email gate, verified email-change flow, and security notifications.

AuthKit ships a lightweight but complete compliance surface covering the most common GDPR/LGPD obligations: data portability (export), right to erasure (account deletion), access control via verified-email gate, a verified email-change flow (no silent address hijacking), and security notifications (automatic alerts when sensitive account events occur).

Email verification gate

Block unverified accounts from authenticating. When login.requireVerifiedEmail is true, any login attempt (password, magic link, or passkey-first) for an account whose email has not been verified is rejected with an instruction to check the inbox first.

config/authkit.ts
defineConfig({
  // …
  login: {
    requireVerifiedEmail: true, // default: false
  },
});

Capability-probed. The check requires the account store to expose isEmailVerified. The bundled lucidAccountStore supports it when your user model has an email_verified_at column. Without the capability the feature degrades to a no-op and authkit:doctor emits a warning.

Grace period

To ease onboarding, the require_verified_email runtime setting accepts an optional graceDays field. Accounts created within the last graceDays days are allowed to log in even without a verified email; after the grace window the normal gate applies.

require_verified_email setting
{
  "enabled": true,
  "graceDays": 3
}

graceDays: 0 (the default) means no grace — the gate is enforced from the first login. See Runtime Settings for the full shape.

Verified email-change flow

When a user changes their email address, AuthKit runs a verified double-confirmation flow — the new address must be confirmed before the change takes effect, and the current address receives a security notice.

Flow overview

  1. User initiates — submits a new email address on /account/security. When requirePassword: true (default), the current password is also required.
  2. Confirmation email — a ec: prefixed token is issued and a confirmation link is sent to the new address. The token reuses the existing email-verification column (no extra column needed).
  3. Notice email — a security alert is sent to the current address so the account owner is aware of the pending change.
  4. Cancellation — the user (or anyone with access to the current address) can cancel the pending change via a link in the notice email, or from /account/security.
  5. Confirmation — the user clicks the link in the email sent to the new address (GET /account/email/confirm?token=ec:…). The change is applied atomically.
  6. Post-change notice — a security notification (email_changed kind) is sent to the old address confirming the change completed.

Configuration

The flow is controlled by the email_change runtime setting (no static-config equivalent):

email_change setting
{
  "enabled": true,
  "ttlHours": 24,
  "requirePassword": true
}
FieldTypeDefaultNotes
enabledboolean?truefalse disables the email-change form entirely.
ttlHoursnumber?24How long the confirmation token is valid.
requirePasswordboolean?trueRequire the current password to initiate a change. Password-less accounts (e.g. social-only) are never asked.

Manage via the Admin Console ({prefix}/settings) or the Admin REST API:

# Shorten the TTL and require no password (passwordless accounts)
curl -X PUT https://auth.acme.com/api/authkit/v1/settings/email_change \
  -H "Authorization: Bearer $KEY" \
  -H "Content-Type: application/json" \
  -d '{ "value": { "ttlHours": 4, "requirePassword": false } }'

# Disable the flow
curl -X PUT https://auth.acme.com/api/authkit/v1/settings/email_change \
  -H "Authorization: Bearer $KEY" \
  -H "Content-Type: application/json" \
  -d '{ "value": { "enabled": false } }'

Mail hooks

Override the default email templates by adding hooks to mail in defineConfig:

config/authkit.ts
import mail from "@adonisjs/mail/services/main";

defineConfig({
  // …
  mail: {
    /**
     * Confirmation link sent to the NEW address.
     * data.oldEmail is available for context in the template.
     */
    onEmailChangeConfirm: async ({ email, confirmUrl, token, oldEmail }) => {
      await mail.send((m) =>
        m
          .from(env.get("EMAIL_FROM"))
          .to(email)
          .subject("Confirm your new email address")
          .text(`Please confirm: ${confirmUrl}`),
      );
    },

    /**
     * Security notice sent to the CURRENT address when a change is requested.
     * Also sent (via onSecurityNotice) after the change is confirmed.
     */
    onEmailChangeNotice: async ({ email, newEmail }) => {
      await mail.send((m) =>
        m
          .from(env.get("EMAIL_FROM"))
          .to(email)
          .subject("Email address change requested")
          .text(`Someone requested to change the address to ${newEmail}.`),
      );
    },
  },
});

If onEmailChangeConfirm is absent, AuthKit falls back to onEmailVerification (if set), and then to the built-in development-mode logger. Configure at least one mail transport for production use.

Account-store capabilities

The lucidAccountStore implements the email-change capability when:

  • The user model has the standard email column (always present).
  • The model has a verification-token column (emailVerificationToken / email_verification_token) — the same column reused with an ec: prefix.

No additional columns or migrations are required beyond what email verification already uses.

Audit events

EventWhen
email_change.requestedUser submits a new email address (token issued).
email_change.cancelledPending change is cancelled (by the user or via the notice link).
email_change.confirmedNew address confirmed; change applied.

Security notifications

AuthKit sends automatic security-alert emails when sensitive events occur on an account. These are separate from the new-device and new-IP alerts (which are in the notifications setting) — they fire after intentional account changes.

Covered events

KindTrigger
password_changedUser changed their own password.
mfa_enabledTOTP was enrolled.
mfa_disabledTOTP was disabled.
passkey_addedA WebAuthn passkey was registered.
passkey_removedA WebAuthn passkey was removed.
email_changedAn email address change was confirmed.

New-device login (login.new_device) is handled separately — it has its own flow and the notifications.newDeviceEmail setting. Similarly, lazy rehash (password.rehashed) is transparent to the user and does not trigger a notification.

Configuration

The security_notifications runtime setting controls the feature:

security_notifications setting
{
  "enabled": true,
  "kinds": [
    "password_changed",
    "mfa_enabled",
    "mfa_disabled",
    "passkey_added",
    "passkey_removed",
    "email_changed"
  ]
}
FieldTypeDefaultNotes
enabledboolean?truefalse disables all security notifications.
kindsstring[]?all 6 kindsSubset of kinds to notify. Invalid kinds are silently dropped. Empty array resets to all.

Manage via the Admin Console or Admin REST API:

# Notify only on password changes and passkey events
curl -X PUT https://auth.acme.com/api/authkit/v1/settings/security_notifications \
  -H "Authorization: Bearer $KEY" \
  -H "Content-Type: application/json" \
  -d '{ "value": { "kinds": ["password_changed", "passkey_added", "passkey_removed"] } }'

# Disable all security notifications
curl -X PUT https://auth.acme.com/api/authkit/v1/settings/security_notifications \
  -H "Authorization: Bearer $KEY" \
  -H "Content-Type: application/json" \
  -d '{ "value": { "enabled": false } }'

Mail hook

Provide a single onSecurityNotice hook that receives the event kind and renders the appropriate template. When absent, AuthKit sends a built-in default email:

config/authkit.ts
import mail from "@adonisjs/mail/services/main";

defineConfig({
  // …
  mail: {
    onSecurityNotice: async ({
      account,
      kind,
      ip,
      userAgent,
      timestamp,
      metadata,
    }) => {
      const subject: Record<string, string> = {
        password_changed: "Your password was changed",
        mfa_enabled: "Two-factor authentication enabled",
        mfa_disabled: "Two-factor authentication disabled",
        passkey_added: "A passkey was added to your account",
        passkey_removed: "A passkey was removed from your account",
        email_changed: "Your email address was changed",
      };
      await mail.send((m) =>
        m
          .from(env.get("EMAIL_FROM"))
          .to(account.email)
          .subject(subject[kind] ?? "Security notification")
          .text(
            `This action occurred on your account at ${timestamp}` +
              (ip ? ` from ${ip}` : ""),
          ),
      );
    },
  },
});

The hook is best-effort and fire-and-forget: a failure never blocks the action that triggered it.

Audit events

EventWhen
security_notice.sentA security notification email was dispatched. metadata.kind contains the event kind.

Data export

Users can download all their personal data from /account/security. The export is a JSON file containing: account metadata (id, email, name, avatar), audit log entries, personal access tokens, linked provider identities, and organization memberships. Secrets (password hash, raw tokens, private keys) are never included.

The endpoint is:

GET /account/security/export

It is behind the account session guard (requires login) and — when rate-limiting is on — applies the login throttle bucket to prevent abuse. On success it emits an account.exported audit event.

Account deletion (self-service)

The Danger zone section on /account/security lets a user delete their own account. The deletion is a transactional cascade that removes:

  • OIDC sessions and grants (access tokens, refresh tokens)
  • Personal access tokens
  • WebAuthn / passkey credentials
  • Linked provider identities (social login)
  • MFA secrets and recovery codes
  • The avatar file (when stored via @adonisjs/drive)
  • Organization memberships and pending invitations

Audit records are anonymized, not deleted: the accountId is replaced with a stable pseudonym (anon:<sha256>) and the personal fields (email, ip) are cleared. The audit trail is preserved for forensics without retaining personal data. The anonymizeAccount method on AuditSink is called if the sink implements it (the bundled lucidAuditSink does).

Diagnostics / Telescope mirror

AuthKit also mirrors every audit event onto the Agora diagnostics bus (agora:authkit:<type>), where Telescope captures it as an independent diagnostic entry in its own store — a store the deletion cascade above does not reach. To keep a deleted account's PII from surviving there, the diagnostics bridge emits a redacted projection of each event: email, ip and the free-form metadata (which can itself carry addresses, e.g. oldEmail / newEmail) are dropped at the source, leaving only the event type and the opaque internal ids (accountId / actorId / clientId) the security dashboard needs for correlation. So Telescope never stores raw PII, and no cross-store purge is needed on deletion. The onEvent callback and outbound webhook (integrations you enable explicitly) still receive the complete event — only the diagnostics mirror is redacted.

The endpoint is:

POST /account/security/delete

On completion it emits an account.deleted audit event.

Admin deletion (via the admin console or API)

Administrators can delete any account from the admin console (DELETE {prefix}/api/users/:id) or the Admin REST API (DELETE /api/authkit/v1/users/:id). The cascade and anonymization are identical to the self-service flow. The operation emits a user.deleted audit event (not account.deleted, to distinguish actor).

curl -X DELETE https://idp.example.com/api/authkit/v1/users/acc-42 \
  -H "Authorization: Bearer $KEY"
{
  "id": "acc-42",
  "deleted": true,
  "sessions": 2,
  "grants": 3,
  "accessTokens": 5,
  "refreshTokens": 2,
  "pats": 1,
  "passkeys": 0,
  "providerIdentities": 1,
  "auditAnonymized": 14,
  "avatarDeleted": true
}

The response includes the cascade counts so callers can verify what was removed.

SDK

const result = await authkit.users.delete("acc-42");
// result.deleted === true
// result.auditAnonymized — how many audit rows were pseudonymized

Durable account-lifecycle workflows

Everything above describes the synchronous path: when a user (or an admin) asks for a deletion or an export, AuthKit runs the whole cascade in-process, inside the HTTP request, and only then answers. That is the default, and for most installations it is the right choice — the code is simpler, the response carries the real cascade counts, and there is nothing extra to operate.

It stops being the right choice when the work outgrows a request. A cascade that touches tens of thousands of audit rows, an export that has to walk a long history, a drive call that hangs — all of them are now sitting between the user and their answer. Worse, the cascade has no memory: if the process is restarted mid-way (a deploy, an OOM kill, a dropped database connection), the steps that already ran are done, the steps that had not run yet are simply lost, and nothing will ever pick the account back up. Each step is best-effort and isolated, so a failure does not abort the rest — but it also does not get retried.

The durable path moves the same cascade onto a workflow engine. Each step becomes a checkpoint: it runs once, its result is persisted, and a run that is interrupted resumes at the first step that had not completed instead of starting over. Nothing already done is done twice, and nothing left undone is forgotten — a run that failed halfway is a run you can drive to completion later, from the engine's own tooling, instead of an account left in an unknown state. A deploy in the middle of a deletion becomes a non-event.

Turning it on

config/authkit.ts
defineConfig({
  // …
  accountLifecycle: {
    durable: true, // default: false — the synchronous path
  },
})

The flag is a single boolean and it is off unless you write durable: true. It changes four endpoints:

EndpointSynchronous (default)Durable
GET /account/security/exportCollects the payload and streams it back as a JSON download.Enqueues authkit.account.export, flashes a "your export was requested" message and redirects back to /account/security. The artifact is produced and delivered by the workflow.
POST /account/security/deleteRuns the whole cascade, then ends the session.Revokes the actor's OIDC sessions and grants synchronously — so the user is logged out immediately, exactly as before — then enqueues authkit.account.delete for the rest of the cascade.
DELETE {prefix}/api/users/:id (admin console)Runs the whole cascade.Enqueues the cascade.
DELETE /api/authkit/v1/users/:id (Admin API)Responds with the cascade counts.Responds { "id": "acc-42", "deleted": true, "enqueued": true, "runId": "…" }.

The Admin API response shape changes with the flag. In durable mode the cascade has not happened yet when the response is written, so there are no counts to report — callers that assert on auditAnonymized or sessions must branch on enqueued instead.

The user.deleted audit event is still written before the enqueue, so the administrative trail exists the moment the request is accepted, regardless of when the cascade finishes.

What the host must install and register

The durable path is built on @adonis-agora/durable, declared as an optional peer dependency. Nothing in the main entry point imports it: the durable code lives behind the @adonis-agora/authkit-server/durable subpath, and the endpoints only reach for that subpath on the branch where accountLifecycle.durable is true. An installation that never turns the flag on never loads a line of it.

Install the peer.

npm i @adonis-agora/durable

Then configure it as its own package documents (an engine needs a store to checkpoint into).

Register both workflows on your engine. AuthKit does not own your WorkflowEngine — it does not create one, and it does not register anything on your behalf. The /durable subpath exports two definitions; you register them.

start/workflows.ts
import app from '@adonisjs/core/services/app'
import { WorkflowEngine } from '@adonis-agora/durable'
import {
  defineAccountDeletionWorkflow,
  defineAccountExportWorkflow,
} from '@adonis-agora/authkit-server/durable'

const engine = await app.container.make(WorkflowEngine)

// Resolved lazily, inside each step — never captured at registration time.
const oidc = () => app.container.make('authkit.server')

const deletion = defineAccountDeletionWorkflow({ oidc })
const dataExport = defineAccountExportWorkflow({ oidc })

engine.register(deletion.name, deletion.version, deletion.body)
engine.register(dataExport.name, dataExport.version, dataExport.body)

Both define* helpers return { name, version, body } — the exact triple engine.register takes. The names are also exported as constants (ACCOUNT_DELETE_WORKFLOW, ACCOUNT_EXPORT_WORKFLOW) so you can reference a run in your own dashboards without hard-coding a string.

Make sure runs actually execute. With the engine's default in-process dispatcher, a run starts executing on the instance that enqueued it, so a single-process app needs nothing further. If you gave the engine a no-op dispatcher because the web pod must not execute workflows, a worker has to poll for pending runs — that is the durable package's concern, not AuthKit's.

The engine is found through the container: resolveWorkflowEngine imports @adonis-agora/durable dynamically and resolves the WorkflowEngine binding from the resolver you hand it (ctx.containerResolver in a request, app.container outside one). The dynamic import is what keeps the peer genuinely optional — the specifier is never resolved unless the durable branch runs.

durable: true without the peer installed fails at request time, not at boot. The first deletion or export to hit the durable branch throws durable account-lifecycle is enabled but "@adonis-agora/durable" is not installed, and the request errors out — nothing is deleted, nothing is exported. Setting the flag and forgetting to register the workflows is quieter and worse: the enqueue succeeds and the run sits there with no registered handler for its name. Exercise both flows in staging after turning the flag on.

The deletion workflow

authkit.account.delete is forward-only — there is no compensation, because a deletion is not undoable. It runs the same cascade as AccountDeletionService, one checkpoint per stage, in the same order:

StepWhat it does
snapshotCaptures the email and avatar URL before anything is destroyed. If the account is already gone (a previous run finished it), the workflow ends as a no-op.
audit.deletedEmits account.deleted before any destruction, so the event carries the real identifiers.
revoke.sessionsOIDC sessions and grants (which cascade to access and refresh tokens).
revoke.patsPersonal access tokens.
remove.passkeysWebAuthn credentials.
disable.mfaTOTP secret and recovery codes.
unlink.providersLinked social identities.
remove.orgsOrganization memberships and pending invitations.
delete.avatarThe avatar file on the drive.
anonymize.auditPseudonymizes the account's audit history.
delete.accountDeletes the account row — deliberately last.

The workflow returns the same DeletionResult counts the synchronous service returns, so whatever you build on top of a completed run sees the familiar shape.

The export workflow

authkit.account.export is read-only plus one artifact, so it also needs no compensation. Its four steps are collect (the same payload the synchronous export streams), audit (emits account.exported), persist, and deliver. It returns { ok, artifactKey, bytes }.

The last two steps are the ones you will want to own:

start/workflows.ts
import mail from '@adonisjs/mail/services/main'
import drive from '@adonisjs/drive/services/main'
import { defineAccountExportWorkflow } from '@adonis-agora/authkit-server/durable'

const dataExport = defineAccountExportWorkflow({
  oidc: () => app.container.make('authkit.server'),

  // Where the serialized JSON goes. Return the key/URL to record on the run.
  persist: async ({ accountId, runId, json }) => {
    const key = `exports/${accountId}/${runId}.json`
    await drive.use('s3').put(key, json, {
      contentType: 'application/json; charset=utf-8',
    })
    return key
  },

  // How the data subject is told the export is ready.
  deliver: async ({ accountId, artifactKey, oidc }) => {
    if (!artifactKey) return
    const account = await oidc.config.accountStore.findById(accountId)
    if (!account) return
    const url = await drive.use('s3').getSignedUrl(artifactKey, { expiresIn: '24h' })
    await mail.send((m) =>
      m.to(account.email).subject('Your data export is ready').text(url),
    )
  },
})

Both are optional. Without persist, AuthKit writes the JSON to the app's drive — the disk configured for avatar uploads, or the default disk — under an authkit/exports prefix, and returns that storage key; if @adonisjs/drive is not installed, persistence is skipped and artifactKey comes back null. Without deliver, nothing is sent: the artifact exists and the run records its key, but the account holder is never told. Plug deliver in. An export the data subject cannot reach does not satisfy a portability request.

Enqueueing from your own code

The same helpers the endpoints use are public, so a support tool or a scheduled job can queue the identical workflows:

app/controllers/gdpr_controller.ts
import type { HttpContext } from '@adonisjs/core/http'
import {
  resolveWorkflowEngine,
  enqueueAccountDeletion,
  enqueueAccountExport,
} from '@adonis-agora/authkit-server/durable'

export default class GdprController {
  async erase(ctx: HttpContext) {
    const engine = await resolveWorkflowEngine(ctx.containerResolver)

    const runId = await enqueueAccountDeletion(engine, {
      accountId: ctx.request.param('id'),
      actor: { actorId: null, ip: ctx.request.ip(), source: 'admin-api' },
    })

    return { enqueued: true, runId }
  }

  async export(ctx: HttpContext) {
    const engine = await resolveWorkflowEngine(ctx.containerResolver)
    const runId = await enqueueAccountExport(engine, {
      accountId: ctx.request.param('id'),
      ip: ctx.request.ip(),
    })
    return { enqueued: true, runId }
  }
}

enqueueDeletionVia(resolver) is the same thing folded into a single callback — it resolves the engine and enqueues in one call, which is the shape AdminUsersService.delete accepts as its optional enqueue argument:

import { enqueueDeletionVia } from '@adonis-agora/authkit-server/durable'

const enqueue = enqueueDeletionVia(ctx.containerResolver)
const runId = await enqueue({ accountId, actor })
ExportSignature
defineAccountDeletionWorkflow(deps: { oidc }) => { name, version, body }
defineAccountExportWorkflow(deps: { oidc, persist?, deliver? }) => { name, version, body }
resolveWorkflowEngine(resolver) => Promise<EnqueueEngine>
enqueueAccountDeletion(engine, { accountId, actor }) => Promise<string> — the run id
enqueueAccountExport(engine, { accountId, ip? }) => Promise<string> — the run id
enqueueDeletionVia(resolver) => (input) => Promise<string>

actor is the same DeletionActor the synchronous service takes: { actorId, ip, source }, where source is 'self', 'admin' or 'admin-api' and ends up in the audit metadata.

Enqueueing twice is safe. Both helpers derive the run id from the workflow name and the account id, so a duplicated request — an impatient double-click, a retried job — lands on the same run instead of starting a second cascade over the same account. Use the returned run id rather than rebuilding it yourself.

Account expiration

Automatically block accounts that have been inactive for a configurable number of days, with an optional warning email before the deadline. This is a GDPR/LGPD hygiene measure — stale accounts are locked, not deleted.

"Last activity" is defined as the timestamp of the most recent login.success event in the audit log for that account. No new column is needed — the feature reads the existing audit trail.

Capability requirement: the audit sink must implement list (the lucidAuditSink does). When the sink is write-only, the feature is unavailable and authkit:doctor explains why.

Configuration

Setting key: account_expiration

{
  "enabled": false,
  "inactiveDays": 365,
  "warnDays": 14
}
FieldTypeDefaultNotes
enabledbooleanfalsetrue activates expiration checks on login and the expire-scan command.
inactiveDaysnumber365Days without a login.success before the account is considered expired. Minimum: 1.
warnDaysnumber14Days before expiration to send the warning email. 0 = no warning.
# Enable with 1-year expiration and 2-week warning
node ace authkit:settings:set account_expiration \
  '{"enabled":true,"inactiveDays":365,"warnDays":14}'

Login behaviour

When a user with an expired account attempts to log in, the login is blocked with a clear i18n message. A link to the password-reset flow is shown — completing a reset registers implicit activity and unblocks the account. The event account.expired_login_blocked is emitted.

Fail-safe: any error during the expiration check (audit lookup failure, DB outage) results in the login being allowed — availability is preferred over enforcement.

expire-scan command

Run this command periodically (e.g. via cron) to scan for and warn expiring accounts:

# Warn accounts expiring within warnDays (sends onAccountExpirationWarning email)
node ace authkit:accounts:expire-scan --warn

# Dry run — print a summary without sending emails or writing audit events
node ace authkit:accounts:expire-scan --warn --dry-run

# Machine-readable JSON output (for log aggregation / alerting)
node ace authkit:accounts:expire-scan --warn --json
FlagNotes
--warnSend the expiration warning email to accounts approaching expiration. Deduplicates: accounts that already have an account.expiration_warned event within the last warnDays days are skipped.
--dry-runPrint the counts without sending emails or writing audit events.
--jsonOutput a JSON object { expired, warned, skipped } to stdout.

Example cron (runs nightly at 02:00):

0 2 * * * node ace authkit:accounts:expire-scan --warn --json >> /var/log/authkit-expire.log

Deduplication: the command checks the audit log for existing account.expiration_warned events within the last warnDays days per account. An account is warned at most once per warning window, even if the cron runs multiple times.

Mail hook

config/authkit.ts
import mail from "@adonisjs/mail/services/main";

defineConfig({
  // …
  mail: {
    onAccountExpirationWarning: async ({ account, expiresAt, daysLeft }) => {
      await mail.send((m) =>
        m
          .from(env.get("EMAIL_FROM"))
          .to(account.email!)
          .subject("Your account will expire soon").html(`
            <p>Your account at Acme will be locked in <strong>${daysLeft} day(s)</strong>
            due to inactivity (last login: ${expiresAt.toLocaleDateString()}).</p>
            <p>Log in to keep your account active.</p>
          `),
      );
    },
  },
});

When onAccountExpirationWarning is absent, AuthKit logs to the console (development) and skips the email send.

Audit events

EventWhen
account.expired_login_blockedA login was blocked because the account has exceeded inactiveDays.
account.expiration_warnedAn expiration warning email was sent (dedup anchor for the expire-scan command).

Audit events

EventWhen
email_change.requestedUser requested an email address change.
email_change.cancelledPending email change was cancelled.
email_change.confirmedEmail address change confirmed (new address activated).
security_notice.sentA security notification email was dispatched.
account.deletedA user deletes their own account (self-service).
account.exportedA user downloads their data export.
user.deletedAn admin deletes a user via the console or API.
account.expired_login_blockedLogin blocked — account exceeded inactivity threshold.
account.expiration_warnedExpiration warning email sent (expire-scan dedup anchor).

On this page