Agora

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 HTTP POST of 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

config/authkit.ts
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.

FieldTypeNotes
onEvent(event) => void | Promise<void>Called for every event. Errors are swallowed.
webhook.urlstringDestination of the POST.
webhook.secretstringWhen 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 typeWhen
login.successA login completed successfully.
login.failureA login attempt failed (wrong credentials).
login.new_ip_notifiedNew-IP email sent after a successful login.
login.new_deviceNew-device notification email sent (no trusted-device cookie).
login.magic_link_sentA magic-link login email was issued and sent.
login.otp_sentAn email login code was issued alongside the magic link. Emitted together with login.magic_link_sent, never on its own.
login.otp_verifiedA login completed by typing the emailed code. Carries the accountId of the account that signed in.
login.otp_failedA 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_invalidatedThe 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.
signupA new account was created.
password_reset.issuedA password-reset token was generated and emailed.
password_reset.consumedA password was reset using the token.
password.changedA user changed their own password.
password.rehashedA hash was transparently upgraded on login (lazy rehash).
pat.issuedA Personal Access Token was created.
pat.revokedA PAT was revoked.
pat.usedA PAT was used in an introspection request.
impersonationAn identity was actually assumed — the RFC 8693 token exchange succeeded. This is the event that says an impersonation happened.
impersonation.panel_viewedAn 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.enabledTOTP was enrolled.
mfa.disabledTOTP was disabled.
account.lockedAn account was locked out after repeated login failures.
passkey.registeredA WebAuthn passkey was registered.
passkey.removedA WebAuthn passkey was removed.
email_verification.issuedAn email verification token was sent.
email_verification.consumedAn email address was verified.
email_change.requestedA user requested an email address change (confirmation link sent to new address).
email_change.cancelledA pending email address change was cancelled.
email_change.confirmedAn email address change was confirmed (new address is now active).
security_notice.sentA security notification email was dispatched. metadata.kind contains the event kind (password_changed, mfa_enabled, etc.).
session.single_enforcedOther sessions were revoked after a login when session_policy.singleSession is true. metadata.revokedSessions contains the count.
session.revokedA single session was revoked.
account.signed_out_allAn account was signed out of every device.
client.secret_regeneratedAn OIDC client's secret was regenerated.
roles_catalog.updatedThe roles_catalog setting was changed.
password.expired_change_forcedLogin was interrupted to force a password change because password_expiration is enabled and the password exceeded maxAgeDays.
client.createdAn OIDC client was created.
client.updatedAn OIDC client was updated.
client.deletedAn OIDC client was deleted.
session.revoked_allAll sessions/grants for an account were revoked.
grant.revoked_by_userA user revoked one app's access grant.
profile.updatedA user updated their display name or avatar.
user.createdAn admin created a user.
user.password_reset_sentAn admin sent a password-reset email for a user.
user.disabledAn admin disabled a user account.
user.enabledAn admin re-enabled a user account.
user.deletedAn admin deleted a user (cascade + anonymize audit).
account.deletedA user deleted their own account (self-service, cascade).
account.exportedA user downloaded their data export.
bot_protection.rejectedA bot-protection check returned false.
keys.rotatedSigning keys were rotated via authkit:keys:rotate.
settings.updatedA runtime setting was written or cleared, from the admin console or the Admin REST API. metadata identifies the key.
trusted_device.revokedA user cleared the trusted-device cookie for the current browser.
organization.createdAn organization was created.
organization.updatedOrganization metadata was updated.
organization.deletedAn organization was deleted.
organization.member_addedA member was added to an organization.
organization.member_removedA member was removed from an organization.
organization.member_role_changedA member's role within an organization was changed.
organization.switchedA user switched their active organization.
organization.deactivatedA user deactivated their active org.
organization.invitation_sentAn org invitation was sent by email.
organization.invitation_acceptedAn org invitation was accepted.
organization.invitation_revokedAn org invitation was revoked.
sudo.confirmedA user confirmed their identity via sudo mode (/account/confirm). metadata contains the method (password or passkey).
otp.lockedThe TOTP/recovery factor was locked after maxAttempts consecutive failures. metadata.maxAttempts contains the threshold.
otp.unlockedThe TOTP/recovery factor was unlocked via the email link.
otp.unlock_failedAn OTP unlock was attempted with an invalid or expired token.
account.expired_login_blockedA login attempt was blocked because the account has exceeded account_expiration.inactiveDays.
account.expiration_warnedAn 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:

config/authkit_server.ts
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:

ChannelGets email/ip/metadataGets orgId
audit sink (your DB)yesyes
events.onEventyesyes
events.webhookyesyes
Agora diagnostics busno — redactedyes

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.

On this page