Events & Webhooks
Observe every security event the IdP audits — via an in-process callback or an HMAC-signed webhook.
Every security-relevant action AuthKit performs (logins, signups, MFA changes, PAT issuance,
client CRUD, impersonation, …) is emitted as an audit event. Beyond the pluggable
audit sink that persists them, the host can observe the
same stream in real time through events:
onEvent— an in-process callback invoked for every event (forward to a bus, a logger, an analytics pipeline).webhook— an HTTPPOSTof the event JSON to an external URL, optionally HMAC-signed.
Both are best-effort and fire-and-forget: a failing handler or an unreachable webhook never throws into the request path.
Configuration
import { defineConfig } from '@adonis-agora/authkit-server'
export default defineConfig({
// ...issuer, clients, accountStore, etc.
// Optional: persist events for the admin console / queries.
audit: lucidAuditSink(AuthEvent),
events: {
onEvent: async (event) => {
// event: { type, accountId, email, clientId, actorId, ip, metadata }
await myBus.publish('authkit', event)
},
webhook: {
url: 'https://hooks.example.com/authkit',
secret: process.env.AUTHKIT_WEBHOOK_SECRET, // optional, enables signing
},
},
})When events is set, the resolved audit sink becomes a fan-out: each record is written
to the original sink (if any) and dispatched to onEvent and the webhook. The
original sink's list() (admin queries) is preserved.
| Field | Type | Notes |
|---|---|---|
onEvent | (event) => void | Promise<void> | Called for every event. Errors are swallowed. |
webhook.url | string | Destination of the POST. |
webhook.secret | string | When set, signs the body (see below). |
Webhook payload
The body is JSON:
{
"type": "login.success",
"accountId": "user-1",
"email": "user@example.com",
"clientId": "app1",
"orgId": null,
"ip": "203.0.113.7",
"metadata": { "mfa": "totp" },
"ts": "2026-06-04T12:00:00.000Z"
}orgId is the organization (tenant) the event belongs to, or null. It is a first-class
field, not a metadata key — see Provisioning your own database
for why that distinction matters.
Sent with content-type: application/json. The request has a 5s timeout (via
AbortSignal) and is never awaited by the request that triggered it.
Signature verification
When webhook.secret is set, AuthKit adds a header:
x-authkit-signature: sha256=<hex HMAC-SHA256 of the raw body>Verify it on the receiver before trusting the payload:
import { createHmac, timingSafeEqual } from 'node:crypto'
function verify(rawBody: string, header: string, secret: string): boolean {
const expected = 'sha256=' + createHmac('sha256', secret).update(rawBody).digest('hex')
const a = Buffer.from(header)
const b = Buffer.from(expected)
return a.length === b.length && timingSafeEqual(a, b)
}Compute the HMAC over the raw request body (the exact bytes received), not a re-serialized object — key ordering and whitespace must match.
Event types
Every AuditEvent carries accountId, email, clientId, actorId (impersonation),
ip, and free-form metadata. The timestamp is set by the sink.
AuditEventType is derived from AUDIT_EVENT_TYPES, a const array exported from
@adonis-agora/authkit-server. Import it when you need the set at runtime — to seed a
filter UI, to validate stored rows, or to assert your own switch still covers
everything:
import { AUDIT_EVENT_TYPES, type AuditEventType } from '@adonis-agora/authkit-server'
const SECURITY_CRITICAL: AuditEventType[] = AUDIT_EVENT_TYPES.filter(
(type) => type.startsWith('impersonation') || type.startsWith('mfa.'),
)The union being derived from a value, rather than written out by hand, is what lets the
library check itself: a spec scans every audit.record() call site and fails when an
event is emitted that the list does not declare.
| Event type | When |
|---|---|
login.success | A login completed successfully. |
login.failure | A login attempt failed (wrong credentials). |
login.new_ip_notified | New-IP email sent after a successful login. |
login.new_device | New-device notification email sent (no trusted-device cookie). |
login.magic_link_sent | A magic-link login email was issued and sent. |
login.otp_sent | An email login code was issued alongside the magic link. Emitted together with login.magic_link_sent, never on its own. |
login.otp_verified | A login completed by typing the emailed code. Carries the accountId of the account that signed in. |
login.otp_failed | A submitted login code was rejected. metadata.reason says why: invalid (wrong code, attempt counted), expired (past the code's TTL), or no_code (nothing pending for this interaction). Carries the submitted email, not an accountId. |
login.otp_invalidated | The attempt counter was exhausted, so the code was disabled. The magic link in the same email stays valid — this is not a lockout of the account. |
signup | A new account was created. |
password_reset.issued | A password-reset token was generated and emailed. |
password_reset.consumed | A password was reset using the token. |
password.changed | A user changed their own password. |
password.rehashed | A hash was transparently upgraded on login (lazy rehash). |
pat.issued | A Personal Access Token was created. |
pat.revoked | A PAT was revoked. |
pat.used | A PAT was used in an introspection request. |
impersonation | An identity was actually assumed — the RFC 8693 token exchange succeeded. This is the event that says an impersonation happened. |
impersonation.panel_viewed | An admin opened the console's impersonation panel and revealed the exchange parameters for a target user. Nothing was assumed: the exchange still has to be run with the admin's own access token, and it may never be. Emitted only once a usable panel exists, so a request that fails leaves no trail. |
mfa.enabled | TOTP was enrolled. |
mfa.disabled | TOTP was disabled. |
account.locked | An account was locked out after repeated login failures. |
passkey.registered | A WebAuthn passkey was registered. |
passkey.removed | A WebAuthn passkey was removed. |
email_verification.issued | An email verification token was sent. |
email_verification.consumed | An email address was verified. |
email_change.requested | A user requested an email address change (confirmation link sent to new address). |
email_change.cancelled | A pending email address change was cancelled. |
email_change.confirmed | An email address change was confirmed (new address is now active). |
security_notice.sent | A security notification email was dispatched. metadata.kind contains the event kind (password_changed, mfa_enabled, etc.). |
session.single_enforced | Other sessions were revoked after a login when session_policy.singleSession is true. metadata.revokedSessions contains the count. |
session.revoked | A single session was revoked. |
account.signed_out_all | An account was signed out of every device. |
client.secret_regenerated | An OIDC client's secret was regenerated. |
roles_catalog.updated | The roles_catalog setting was changed. |
password.expired_change_forced | Login was interrupted to force a password change because password_expiration is enabled and the password exceeded maxAgeDays. |
client.created | An OIDC client was created. |
client.updated | An OIDC client was updated. |
client.deleted | An OIDC client was deleted. |
session.revoked_all | All sessions/grants for an account were revoked. |
grant.revoked_by_user | A user revoked one app's access grant. |
profile.updated | A user updated their display name or avatar. |
user.created | An admin created a user. |
user.password_reset_sent | An admin sent a password-reset email for a user. |
user.disabled | An admin disabled a user account. |
user.enabled | An admin re-enabled a user account. |
user.deleted | An admin deleted a user (cascade + anonymize audit). |
account.deleted | A user deleted their own account (self-service, cascade). |
account.exported | A user downloaded their data export. |
bot_protection.rejected | A bot-protection check returned false. |
keys.rotated | Signing keys were rotated via authkit:keys:rotate. |
settings.updated | A runtime setting was written or cleared, from the admin console or the Admin REST API. metadata identifies the key. |
trusted_device.revoked | A user cleared the trusted-device cookie for the current browser. |
organization.created | An organization was created. |
organization.updated | Organization metadata was updated. |
organization.deleted | An organization was deleted. |
organization.member_added | A member was added to an organization. |
organization.member_removed | A member was removed from an organization. |
organization.member_role_changed | A member's role within an organization was changed. |
organization.switched | A user switched their active organization. |
organization.deactivated | A user deactivated their active org. |
organization.invitation_sent | An org invitation was sent by email. |
organization.invitation_accepted | An org invitation was accepted. |
organization.invitation_revoked | An org invitation was revoked. |
sudo.confirmed | A user confirmed their identity via sudo mode (/account/confirm). metadata contains the method (password or passkey). |
otp.locked | The TOTP/recovery factor was locked after maxAttempts consecutive failures. metadata.maxAttempts contains the threshold. |
otp.unlocked | The TOTP/recovery factor was unlocked via the email link. |
otp.unlock_failed | An OTP unlock was attempted with an invalid or expired token. |
account.expired_login_blocked | A login attempt was blocked because the account has exceeded account_expiration.inactiveDays. |
account.expiration_warned | An expiration warning email was dispatched by the expire-scan command (used as a dedup anchor). |
Provisioning your own database
You do not need any other Agora library to react to what the IdP does. Both onEvent and
the webhook receive the complete event, so a host can keep its own tables in sync —
create a companies row when an organization is created, revoke an app-side membership
when a member is removed, and so on:
defineConfig({
// …
events: {
onEvent: async (event) => {
if (event.type === 'organization.created') {
await Company.create({ authOrgId: event.orgId!, slug: event.metadata?.slug as string })
}
if (event.type === 'organization.member_removed') {
await Membership.query()
.where('company_id', event.orgId!)
.where('user_id', event.metadata?.targetAccountId as string)
.delete()
}
},
},
})Use event.orgId — not event.metadata.orgId — as the tenant key. Both are populated on
organization events, but only the first-class field is guaranteed across every delivery
channel:
| Channel | Gets email/ip/metadata | Gets orgId |
|---|---|---|
audit sink (your DB) | yes | yes |
events.onEvent | yes | yes |
events.webhook | yes | yes |
| Agora diagnostics bus | no — redacted | yes |
The last row is the reason orgId is promoted: the diagnostics bus mirrors every audit event
for Telescope and any onDiagnostic('authkit', …) subscriber, and that mirror is stripped of
PII (email, ip, and the free-form metadata, which may carry an invitee's address). Only
the opaque internal ids survive — accountId, actorId, clientId, orgId. That keeps a
bus subscriber able to answer which tenant without ever seeing personal data.
Handlers are best-effort and fire-and-forget: AuthKit swallows their errors so a failing write never breaks a login. If your provisioning must not be lost, write to a queue/outbox from the handler rather than doing the work inline.