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.
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.
{
"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
- User initiates — submits a new email address on
/account/security. WhenrequirePassword: true(default), the current password is also required. - 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). - Notice email — a security alert is sent to the current address so the account owner is aware of the pending change.
- 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. - 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. - Post-change notice — a security notification (
email_changedkind) 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):
{
"enabled": true,
"ttlHours": 24,
"requirePassword": true
}| Field | Type | Default | Notes |
|---|---|---|---|
enabled | boolean? | true | false disables the email-change form entirely. |
ttlHours | number? | 24 | How long the confirmation token is valid. |
requirePassword | boolean? | true | Require 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:
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
emailcolumn (always present). - The model has a verification-token column (
emailVerificationToken/email_verification_token) — the same column reused with anec:prefix.
No additional columns or migrations are required beyond what email verification already uses.
Audit events
| Event | When |
|---|---|
email_change.requested | User submits a new email address (token issued). |
email_change.cancelled | Pending change is cancelled (by the user or via the notice link). |
email_change.confirmed | New 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
| Kind | Trigger |
|---|---|
password_changed | User changed their own password. |
mfa_enabled | TOTP was enrolled. |
mfa_disabled | TOTP was disabled. |
passkey_added | A WebAuthn passkey was registered. |
passkey_removed | A WebAuthn passkey was removed. |
email_changed | An 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:
{
"enabled": true,
"kinds": [
"password_changed",
"mfa_enabled",
"mfa_disabled",
"passkey_added",
"passkey_removed",
"email_changed"
]
}| Field | Type | Default | Notes |
|---|---|---|---|
enabled | boolean? | true | false disables all security notifications. |
kinds | string[]? | all 6 kinds | Subset 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:
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
| Event | When |
|---|---|
security_notice.sent | A 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/exportIt 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/deleteOn 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 pseudonymizedDurable 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
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:
| Endpoint | Synchronous (default) | Durable |
|---|---|---|
GET /account/security/export | Collects 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/delete | Runs 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/durableThen 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.
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:
| Step | What it does |
|---|---|
snapshot | Captures 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.deleted | Emits account.deleted before any destruction, so the event carries the real identifiers. |
revoke.sessions | OIDC sessions and grants (which cascade to access and refresh tokens). |
revoke.pats | Personal access tokens. |
remove.passkeys | WebAuthn credentials. |
disable.mfa | TOTP secret and recovery codes. |
unlink.providers | Linked social identities. |
remove.orgs | Organization memberships and pending invitations. |
delete.avatar | The avatar file on the drive. |
anonymize.audit | Pseudonymizes the account's audit history. |
delete.account | Deletes 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:
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:
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 })| Export | Signature |
|---|---|
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
}| Field | Type | Default | Notes |
|---|---|---|---|
enabled | boolean | false | true activates expiration checks on login and the expire-scan command. |
inactiveDays | number | 365 | Days without a login.success before the account is considered expired. Minimum: 1. |
warnDays | number | 14 | Days 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| Flag | Notes |
|---|---|
--warn | Send 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-run | Print the counts without sending emails or writing audit events. |
--json | Output 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.logDeduplication: 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
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
| Event | When |
|---|---|
account.expired_login_blocked | A login was blocked because the account has exceeded inactiveDays. |
account.expiration_warned | An expiration warning email was sent (dedup anchor for the expire-scan command). |
Audit events
| Event | When |
|---|---|
email_change.requested | User requested an email address change. |
email_change.cancelled | Pending email change was cancelled. |
email_change.confirmed | Email address change confirmed (new address activated). |
security_notice.sent | A security notification email was dispatched. |
account.deleted | A user deletes their own account (self-service). |
account.exported | A user downloads their data export. |
user.deleted | An admin deletes a user via the console or API. |
account.expired_login_blocked | Login blocked — account exceeded inactivity threshold. |
account.expiration_warned | Expiration warning email sent (expire-scan dedup anchor). |