Agora

Security

Rate-limiting, audit logging, bot protection, RP-initiated logout, JWT access tokens, key rotation, and mail hooks.

AuthKit bundles the security plumbing an Authorization Server needs — most of it opt-in.

Rate-limiting

Anti-brute-force throttles sit on the sensitive routes, backed by @adonisjs/limiter. They are on by default — you opt out, not in:

config/authkit.ts
rateLimit: {
  enabled: true,     // default
  // store: 'redis', // optional — a store from config/limiter.ts
}

The host must have @adonisjs/limiter configured (config/limiter.ts). Without it every throttle degrades to a no-op: the routes still work, they simply are not throttled. That is a deliberate fail-safe — a missing optional peer must never take the login down — but it also means "throttled" is a claim worth verifying in production rather than assuming.

registerAuthHost accepts the same rateLimit option, for hosts that configure routing without a server config. When both declare it, the config wins and locks the option: a routes.ts that passed { enabled: false } cannot switch off protection the config turned on.

Five buckets exist, each with its own budget, and they are separate on purpose — two different questions must not consume one another's allowance:

BucketKeyed byBudgetGuards
loginIP10 / minThe interactive credential routes — login, sign-up, forgot, reset, MFA, magic-link request.
otpLoginIP5 / minPOST /auth/interaction/:uid/otp-verify. Tighter than login because a six-digit code is guessable in a way a token is not.
sudoIP10 / minThe sudo-mode confirmation routes. Same numbers as login, separate budget: login measures an anonymous stranger guessing credentials, sudo measures a signed-in user re-proving themselves.
introspectionIP or bearer secret60 / min/authkit/pat/introspect.
adminIpIP30 / minThe Admin REST API group. Keyed by IP rather than by key, so trying a thousand different API keys from one address still hits one budget.

The bucket budgets above come from the library defaults. rateLimit accepts enabled and store only — the points and windows are not configurable through it, and the rate_limit runtime setting is stored and validated but is not read by the throttles.

Account lockout

Complementary to the IP-keyed rate-limit, account lockout throttles by email: after repeated password failures an account is locked for a progressively-backing-off duration. On by default (no-op without the limiter), it emits an account.locked audit event while locked. See Account Lockout for the full field reference and runtime-setting CLI examples.

New-device login notification

On a successful login without a trusted-device cookie (the same signal GitHub uses), the host kit sends a "new device login" email to the account owner. The trigger is the absence of a valid trusted-device cookie — it works whether or not the user ever ticked "trust this device".

It is on by default. To opt out, update the notifications runtime setting:

node ace authkit:settings:set notifications '{"newDeviceEmail":false}'

Override the email template:

mail: {
  onNewDeviceLogin: async ({ account, ip, userAgent, timestamp }) => {
    await mailer.send((m) =>
      m.from(env.get('EMAIL_FROM')).to(account.email!)
        .subject('New sign-in to your account')
        .text(`New login from ${ip ?? 'unknown'} at ${timestamp}`)
    )
  },
}

When it fires it emits a login.new_device audit event.

New-login email

On a successful login, the host kit can send a "new access to your account" email when the login comes from an IP never seen before for that account. The check queries the audit sink for prior login.success events of the same subject — so it requires an AuditSink that implements list; with a write-only sink (or no sink) it degrades to a no-op. The email includes the IP and timestamp and is sent through the default mailer.

It is on by default. To opt out, update the notifications runtime setting:

node ace authkit:settings:set notifications '{"newLoginEmail":false}'

The notification is fail-safe: it runs fire-and-forget after the login completes and never blocks or throws into the login path. When it fires, it emits a login.new_ip_notified audit event (alongside the usual login.success).

Bot protection

Pluggable CAPTCHA/challenge protection for the login, signup, and reset flows. AuthKit is vendor-agnostic: you supply a verify function; the lib injects the widget HTML into the affected pages and calls verify before processing credentials.

config/authkit.ts
botProtection: {
  on: ['login', 'signup'],  // default; 'reset' also available
  widget: {
    scriptUrl: 'https://challenges.cloudflare.com/turnstile/v0/api.js',
    html: '<div class="cf-turnstile" data-sitekey="0xAAA..."></div>',
  },
  async verify({ token, ip, action }) {
    if (!token) return false
    const res = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', {
      method: 'POST',
      headers: { 'content-type': 'application/x-www-form-urlencoded' },
      body: new URLSearchParams({
        secret: process.env.TURNSTILE_SECRET!,
        response: token,
        ...(ip ? { remoteip: ip } : {}),
      }),
    })
    const data = await res.json() as { success: boolean }
    return data.success === true
  },
},

hCaptcha example:

botProtection: {
  tokenFields: ['h-captcha-response'], // override the default field list
  widget: {
    scriptUrl: 'https://js.hcaptcha.com/1/api.js',
    html: '<div class="h-captcha" data-sitekey="YOUR_SITE_KEY"></div>',
  },
  async verify({ token, ip }) {
    if (!token) return false
    const res = await fetch('https://api.hcaptcha.com/siteverify', {
      method: 'POST',
      headers: { 'content-type': 'application/x-www-form-urlencoded' },
      body: new URLSearchParams({ secret: process.env.HCAPTCHA_SECRET!, response: token }),
    })
    const data = await res.json() as { success: boolean }
    return data.success === true
  },
},
OptionTypeDefaultNotes
verify(input) => Promise<boolean>Required. Return true = human, false = reject.
on('login' | 'signup' | 'reset')[]['login','signup']Which flows to protect.
widget.scriptUrlstringExternal script loaded async on the affected pages.
widget.htmlstringContainer injected raw into the form.
tokenFieldsstring[]['cf-turnstile-response', 'h-captcha-response', 'g-recaptcha-response', 'authkit-bot-token']Body fields checked in order for the token.
timeoutMsnumber5000Abort verify after this many ms.

Fail-safe: if verify throws or times out the request is allowed and a warning is logged. Only an explicit false return rejects. A rejection emits a bot_protection.rejected audit event.

Runtime toggle

When the optional auth_settings table is present (see Reference — Runtime Settings), an admin can enable or disable bot protection and override which flows are protected without redeploying. The effective config is resolved on every request with a 15-second in-memory cache.

Setting key: bot_protection

{
  "enabled": true,
  "on": ["login", "signup", "reset"]
}
FieldTypeNotes
enabledbooleantrue = use config as-is (with optional on override). false = disable at runtime.
on('login' | 'signup' | 'reset')[]Optional. When present and enabled: true, replaces config.on. Omit to keep the config value.

Precedence rules:

  1. botProtection.verify absent in config — the feature does not exist; any stored setting is ignored.
  2. auth_settings table absent or error — falls back to config (zero breaking change; fail-safe).
  3. Setting present, enabled: false — bot protection is off at runtime regardless of config.
  4. Setting present, enabled: true, on provided — uses on from the setting, verify/widget/tokenFields/timeoutMs from config.
  5. Setting present, enabled: true, on absent — uses on from config unchanged.

Note: verify always comes from your config (config/authkit.ts). It is code, not serialisable data, and the setting cannot replace it.

Table schema (create with a migration of your own — the table is optional):

CREATE TABLE auth_settings (
  key        TEXT PRIMARY KEY,
  value      TEXT NOT NULL,        -- JSON
  updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  updated_by TEXT                  -- nullable account id of the admin who wrote it
);

The toggle is surfaced in the admin console at /admin/settings (see Admin Console — Settings page) and via the Admin REST API / SDK (settings.set('bot_protection', { ... })).

Registration toggle

Controls whether public sign-up is open or closed at runtime — without a redeploy. The static fallback is registration.enabled in defineConfig (defaults to true).

config/authkit.ts
defineConfig({
  // …
  registration: { enabled: true }, // static default; overridable at runtime
})

Setting key: registration

{ "enabled": false }
FieldTypeNotes
enabledbooleanfalse closes public sign-up at runtime.

Behaviour when closed:

  • The sign-up page displays the "registration is currently disabled" message from the i18n catalog (errors.registration_disabled).
  • POST /auth/signup rejects the submission.
  • The sign-up link is hidden from the login screen.
  • Org invitations and admin-created accounts are not affected — those are privileged flows that bypass the registration guard.

Precedence rules:

  1. auth_settings table absent or error → uses config.registration.enabled (fail-safe).
  2. Setting present → overrides the static config value.

The toggle is surfaced in the admin console at /admin/settings and via the Admin REST API:

# Close registration at runtime
curl -X PUT https://idp.acme.com/api/authkit/v1/settings/registration \
  -H "Authorization: Bearer $KEY" \
  -H "Content-Type: application/json" \
  -d '{ "value": { "enabled": false } }'

# Re-open
curl -X DELETE https://idp.acme.com/api/authkit/v1/settings/registration \
  -H "Authorization: Bearer $KEY"

Require verified email toggle

Overrides login.requireVerifiedEmail for all three login flows (password, magic link, passkey-first) at runtime.

config/authkit.ts
defineConfig({
  // …
  login: { requireVerifiedEmail: false }, // static default; overridable at runtime
})

Setting key: require_verified_email

{ "enabled": true }
FieldTypeNotes
enabledbooleantrue blocks logins for accounts whose email is unverified.

Precedence rules:

  1. auth_settings table absent or error → uses config.login.requireVerifiedEmail (fail-safe).
  2. Setting present → overrides the static config value for all three login flows.

Note: the check is capability-probed — when the accountStore does not implement isEmailVerified, the guard is a no-op regardless of the setting value. authkit:doctor warns when the setting is true but the capability is absent.

The toggle is surfaced in the admin console at /admin/settings and via the Admin REST API:

curl -X PUT https://idp.acme.com/api/authkit/v1/settings/require_verified_email \
  -H "Authorization: Bearer $KEY" \
  -H "Content-Type: application/json" \
  -d '{ "value": { "enabled": true } }'

Maintenance mode

Puts the IdP into maintenance mode at runtime: login, sign-up, and forgot-password flows are blocked for non-admin users, showing a custom maintenance page. Admin accounts continue to log in normally — the operator is never locked out.

Setting key: maintenance_mode

{
  "enabled": true,
  "message": "We are upgrading our systems. Back in a few minutes."
}
FieldTypeNotes
enabledbooleantrue activates maintenance mode.
messagestring?Optional custom message shown on the maintenance page. Defaults to the built-in i18n string.

Behaviour during maintenance:

  • The login, sign-up, forgot-password, and interaction pages redirect to a maintenance screen.
  • POST submissions on those flows are rejected.
  • Existing sessions and refresh / introspection / userinfo flows continue to work — active sessions are not disrupted.
  • Accounts whose global roles include any value in admin.roles bypass the maintenance gate and can still log in.
  • The Admin Console and Admin REST API are never blocked by maintenance mode.

Escape hatch — Admin REST API:

If the admin console is unreachable from the browser (e.g. during an infrastructure incident), disable maintenance mode directly via the API key-authenticated Admin REST API without needing an active session:

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

Audit: writing the setting — from the admin console or the Admin REST API — emits a settings.updated audit event whose metadata.key is maintenance_mode.

Precedence rules:

  1. auth_settings table absent or error → { enabled: false } (system is up; fail-safe).
  2. Setting present → uses the stored value.

The toggle is surfaced in the admin console at {prefix}/settings alongside the other runtime setting cards.

Authentication methods

Controls which login methods the sign-in screen offers at runtime — without a redeploy. All fields are optional; absent fields fall back to the config / capability defaults.

Setting key: auth_methods

{
  "password": true,
  "magicLink": false,
  "passkey": true,
  "social": ["google"],
  "forgotPassword": true
}
FieldTypeNotes
passwordboolean?Show the email + password form. Default: true.
magicLinkboolean?Show the magic-link button. Default: true when the store and mail capability support it.
passkeyboolean?Show passkey-first sign-in. Default: true when WebAuthn is configured.
socialstring[]?Provider ids to display (e.g. ["google", "github"]). Default: all configured providers. The setting can only filter; it cannot activate a provider the code does not have wired up.
forgotPasswordboolean?Show the "forgot my password" link. Default: true. Auto-derived: always false when password is effectively off — there is nothing to reset without a password.
passkeyAutofillboolean?WebAuthn conditional mediation on the login screen — the email input offers the browser's discoverable passkeys inline. Default: true when passkey is on. Browsers without support simply ignore it and the ordinary login continues.

Precedence rules:

  1. auth_settings table absent or DB error → config-derived defaults (fail-safe).
  2. Setting present, field set → the field's value overrides the default.
  3. Setting present, field absent → the config-derived default for that field is used.
  4. forgotPassword → computed as (setting.forgotPassword ?? true) && passwordEnabled. Turning off password silently turns off forgotPassword as well, regardless of the stored value.
  5. social → intersection of the stored list with config.social.providers. Providers not wired in the static config are silently dropped; the UI never shows a button it cannot handle.

Fail-safe all-off: if the resolved value would leave every method off (no password, no magic link, no passkey, and an empty social list), AuthKit logs a warning and falls back to the config-derived defaults. An empty login screen is never surfaced to users.

Effect on the UI / endpoints:

  • password: false — the password input and submit button are removed from the login form.
  • forgotPassword: false (or auto-derived) — the "forgot my password" link is removed and the GET /auth/forgot-password + POST /auth/forgot-password endpoints return 404.
  • magicLink: false — the magic-link button is hidden and the magic-link endpoints are gated (404).
  • passkey: false — the passkey-first button is hidden.
  • social filtered — only the listed provider buttons are rendered; buttons for providers outside the list are not rendered.

authkit:doctor checks:

  • Shape invalid (e.g. social is not an array, password is not a boolean) → warn.
  • All methods effectively off (all-off scenario) → warn.
  • Provider in the stored social list not present in config.social.providerswarn.

The toggle is surfaced in the admin console at {prefix}/settings (see Admin Console — Authentication methods card) and via the Admin REST API:

# Disable magic link and limit social to Google only
curl -X PUT https://idp.acme.com/api/authkit/v1/settings/auth_methods \
  -H "Authorization: Bearer $KEY" \
  -H "Content-Type: application/json" \
  -d '{ "value": { "magicLink": false, "social": ["google"] } }'

# Reset — returns to config-derived defaults
curl -X DELETE https://idp.acme.com/api/authkit/v1/settings/auth_methods \
  -H "Authorization: Bearer $KEY"

Audit logging

Provide an AuditSink to capture security-relevant events. It is best-effort — record must never throw into the request path. lucidAuditSink(Model) persists to a Lucid model (compose it with withAuditLog).

config/authkit.ts
import AuditLog from '#models/audit_log'
import { lucidAuditSink } from '@adonis-agora/authkit-server'

audit: lucidAuditSink(AuditLog),

For the full list of audit event types see Events & Webhooks.

Each AuditEvent carries accountId, email, clientId, actorId (impersonation), ip, and free-form metadata. The timestamp is set by the sink.

RP-initiated logout

Use buildEndSessionUrl from the client to end the IdP SSO session and bounce the browser to a registered post_logout_redirect_uri:

import { buildEndSessionUrl } from '@adonis-agora/authkit-client'

response.redirect(
  buildEndSessionUrl({
    issuer,                              // e.g. http://localhost:3333/oidc
    idToken: tokens.idToken,             // id_token_hint — skips the confirm page
    postLogoutRedirectUri: 'https://my-app.example.com/',
    clientId,                            // some IdPs require it without id_token_hint
    state,                               // optional; echoed back
  })
)

It targets ${issuer}/session/end. Without id_token_hint, the provider renders a confirmation page. Register the postLogoutRedirectUris on the client (see the example configs in Topologies).

For server-to-server session termination — where the IdP notifies the RP out-of-band — use Back-Channel Logout (declare a backchannelLogoutUri on the client and validate the logout_token).

Dynamic registration auth

When Dynamic Client Registration is enabled, gate the registration endpoint with an initialAccessToken (RFC 7591 §3). Without it, registration is open — anyone can register a client, which is rarely desirable in production.

The RFC 7592 management endpoints (dynamicRegistration.management) require dynamic registration itself to be enabled — turning on management while registration is off is a resolve-time error (the config refuses to construct), so you can't accidentally expose client management without the registration flow that backs it.

Sudo mode

Sudo mode gates sensitive self-service actions behind a re-authentication step. After a user confirms their identity (password or passkey), the IdP records a timestamp in the session (authkit_sudo_at). While within the grace window any protected action succeeds without another prompt; after the window the user is redirected to /account/confirm?return_to=<path>.

Actions gated by requireSudo in the bundled account console:

  • Changing password or email address
  • Deleting the account
  • Enrolling or removing MFA / passkeys
  • Creating, viewing, or revoking Personal Access Tokens

Setting key: sudo_mode

{
  "enabled": true,
  "graceMinutes": 15
}
FieldTypeDefaultNotes
enabledbooleantruefalse disables the gate entirely — no confirmation required.
graceMinutesnumber15Minutes after a successful confirmation before re-prompting. Minimum: 0 (prompt every time).

Fail-safe: if the settings table is absent or the lookup fails, requireSudo defaults to the lib default (enabled: true, graceMinutes: 15). Any error during the guard resolves to true (allow), so a DB hiccup never blocks the user.

Audit: every confirmation emits a sudo.confirmed event. The session key authkit_sudo_at contains the Unix timestamp (ms) of the last confirmation.

# Shorten the grace window
node ace authkit:settings:set sudo_mode '{"enabled":true,"graceMinutes":5}'

# Disable sudo mode entirely
node ace authkit:settings:set sudo_mode '{"enabled":false}'

OTP lockout

OTP lockout prevents brute-forcing the second-factor step by locking TOTP and recovery code verification after repeated failures — independently of the password-level account lockout.

Unlike account lockout (per-email, keyed by the limiter), OTP lockout is keyed by accountId and locks the factor rather than the full account. The user can still reach the password step; they cannot complete the TOTP/recovery step until they unlock their factor.

Both TOTP codes and recovery codes count toward the limit.

Setting key: otp_lockout

{
  "enabled": true,
  "maxAttempts": 5,
  "unlockTtlHours": 24
}
FieldTypeDefaultNotes
enabledbooleantruefalse disables OTP lockout. No-op if @adonisjs/limiter is not installed.
maxAttemptsnumber5Consecutive failures before the factor is locked. Minimum: 1.
unlockTtlHoursnumber24How long the lock lasts (hours). The unlock-by-email token uses the same TTL.

Infrastructure: backed by the same @adonisjs/limiter peer as account lockout — no extra migration, no extra table. No-op when the limiter isn't installed.

Fail-safe: any limiter error is swallowed — a limiter outage never blocks the login.

OTP unlock flow

When the factor is locked, the login challenge step shows an error and sends an unlock email via mail.onOtpUnlock. The user clicks a single-use link:

GET /auth/otp-unlock/:token

The link carries a single-use, high-entropy token; only a hash of it is ever persisted, and it reuses storage the account row already has, so unlocking needs no new column and no migration. On a valid click the failure counters are cleared, the stored token is discarded, and an otp.unlocked audit event is emitted. A token that is unknown, already used, or past its TTL emits otp.unlock_failed and changes nothing.

Mail hook:

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

defineConfig({
  // …
  mail: {
    onOtpUnlock: async ({ email, unlockUrl, token }) => {
      await mail.send((m) =>
        m.from(env.get('EMAIL_FROM')).to(email)
          .subject('Unlock your two-factor authentication')
          .text(`Click the link to unlock your 2FA: ${unlockUrl}`)
      )
    },
  },
})

When onOtpUnlock is absent, the host kit sends the unlock email itself through the default mailer, with branding and i18n applied — see Mail hooks for the two-tier arrangement every email follows.

Audit events:

EventWhen
otp.lockedFactor locked after maxAttempts consecutive failures. metadata.maxAttempts contains the threshold.
otp.unlockedFactor unlocked via the email link.
otp.unlock_failedUnlock attempted with an invalid or expired token.
# Change the lockout threshold
node ace authkit:settings:set otp_lockout '{"enabled":true,"maxAttempts":3,"unlockTtlHours":12}'

Admin console hardening

The /admin/* console is off by default. When admin.enabled is false, the adminGuard responds with 404 Not Found rather than a redirect or 403 — a disabled console is indistinguishable from one that was never mounted, so its existence isn't leaked. See Admin Console.

Mail hooks

Every email AuthKit sends goes out through the same two-tier arrangement, and understanding it removes most of the surprises.

Tier one is your hook. mail.<hook> in config/authkit.ts is a plain async function that receives everything needed to compose the message — the destination address, the fully-built URL, and the raw token where one exists. When the hook is present it is the send: AuthKit calls it and does nothing else, and you own the subject, the template, and the from.

Tier two is the bundled mailer. When the hook is absent, the host kit sends the message itself through the host's default @adonisjs/mail mailer, with responsive HTML, your branding, and the i18n catalog already applied. Only when @adonisjs/mail is not installed or configured does it fall back to logging the URL — that is a development convenience, not the normal path.

Every hook is best-effort and fire-and-forget: a throwing hook never breaks the flow that triggered it.

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

mail: {
  onPasswordReset: async ({ email, resetUrl }) => {
    await mail.send((m) =>
      m.from(env.get('EMAIL_FROM')).to(email)
        .subject('Reset your password')
        .text(`Reset your password: ${resetUrl}`)
    )
  },
  onEmailVerification: async ({ email, verifyUrl }) => {
    await mail.send((m) =>
      m.from(env.get('EMAIL_FROM')).to(email)
        .subject('Verify your email')
        .text(`Confirm your email: ${verifyUrl}`)
    )
  },
  /** OTP lockout — unlock the second factor by email. See Security — OTP lockout. */
  onOtpUnlock: async ({ email, unlockUrl }) => {
    await mail.send((m) =>
      m.from(env.get('EMAIL_FROM')).to(email)
        .subject('Unlock your two-factor authentication')
        .text(`Click to unlock 2FA: ${unlockUrl}`)
    )
  },
  /** Account expiration warning. See Compliance — Account expiration. */
  onAccountExpirationWarning: async ({ email, expiresInDays }) => {
    await mail.send((m) =>
      m.from(env.get('EMAIL_FROM')).to(email)
        .subject('Your account will be deactivated soon')
        .text(`Your account will be deactivated in ${expiresInDays} day(s) due to inactivity.`)
    )
  },
}

Every hook

HookPayloadWhen sent
onPasswordReset{ email, resetUrl, token }A password-reset token was issued.
onEmailVerification{ email, verifyUrl, token }An email-verification token was issued.
onMagicLink{ email, magicUrl, token, code?, channel? }A magic link was issued. code is present only when email login codes are enabled; channel ('code' or 'link') carries the user's choose-first selection so you can render a code-only or link-only email. Both are absent in the plain magic-link flow.
onSudoLink{ email, sudoUrl }A sudo-mode confirmation link was requested. Distinct from onMagicLink on purpose: a magic link authenticates, a sudo link only grants sudo to someone already signed in, and the two tokens share nothing.
onNewDeviceLogin{ account, ip, userAgent, timestamp }A login succeeded from a device with no valid trusted-device cookie.
onOrgInvitation{ email, invitationId, orgName, orgSlug, role, acceptUrl, token }An organization invitation was created, from the account console or the Admin API. Without the hook the bundled mailer sends the invitation, carrying the org name, the role being offered, and the accept link.
onEmailChangeConfirm{ email, confirmUrl, token, oldEmail }An email change was requested; the confirmation link goes to the new address. oldEmail is the current one, for context in the template.
onEmailChangeNotice{ email, newEmail }The same request, notifying the current address that a change was asked for.
onOtpUnlock{ email, unlockUrl, token }The TOTP/recovery factor was locked; the unlock link was issued.
onAccountExpirationWarning{ email, expiresInDays }authkit:expire-scan found an account about to be deactivated for inactivity. Deduplicated by the account_expiration warning window, so an account gets at most one warning per window.
onSecurityNotice{ account, kind, ip, userAgent, timestamp, metadata? }A security-relevant change happened. kind is one of password_changed, mfa_enabled, mfa_disabled, passkey_added, passkey_removed, email_changed; metadata carries extras such as the old and new address for email_changed.

from — a sender just for auth mail

mail.from sets the sender of the emails the library itself sends. It takes precedence over the host's global config/mail.ts sender, which lets security mail come from its own address without disturbing the rest of the application's mail:

config/authkit.ts
mail: {
  from: { address: 'no-reply-auth@acme.com', name: 'Acme Security' },
},

Absent, it falls back to the host's config.mail.from, and finally to the @adonisjs/mail default. It has no effect on your own hooks — there, you build the message, so you build the from.

Every link AuthKit emails — password reset, magic link, OTP unlock, organization invitation, email verification and change, security notices — is built from new URL(issuer).origin. Never from request.host(), and never from X-Forwarded-Proto.

That is not an arbitrary preference. Both of those are client-supplied. An attacker who can reach the application directly — routine when it sits behind a load balancer that does not pin Host — can request a password reset for a victim's address while supplying a Host of their own choosing. The victim then receives a genuine email, from the real system, whose link points at attacker-controlled infrastructure. That is classic password-reset poisoning, and deriving the origin from the issuer closes it by construction.

mail.origin is the escape hatch for the one legitimate case this rules out: an issuer genuinely served under several public hostnames, where emailed links must follow a fixed hostname other than the issuer's.

config/authkit.ts
mail: {
  origin: 'https://login.acme.com', // scheme://host[:port]
},

If you have a single issuer — the ordinary case — you do not need this field, and setting it to anything derived from an incoming request reopens exactly the hole the default closes.

JWT access tokens (RFC 9068)

By default AuthKit emits opaque access tokens. Switch to self-contained JWTs that a resource server can verify locally without calling /introspect:

config/authkit.ts
accessTokens: {
  format: 'jwt',       // all ATs become JWT RFC 9068
  audience: 'https://api.acme.com', // aud claim; default: the issuer
},

For per-API configuration use resources (RFC 8707 resource indicators):

accessTokens: {
  format: 'opaque',    // default for unlabeled requests
  resources: {
    'https://api.acme.com': {
      audience: 'https://api.acme.com',
      scopes: ['read:items', 'write:items'],
      format: 'jwt',
      expiresIn: 900, // 15 min
    },
    'https://admin.acme.com': {
      audience: 'https://admin.acme.com',
      format: 'jwt',
    },
  },
},

JWT ATs carry: iss, sub, aud, exp, iat, jti, client_id, scope, and typ: at+jwt (RFC 9068 §2.1). Verify them on a resource server with verifyJwtAccessToken from the client package — see Client.

OptionTypeDefaultNotes
format'opaque' | 'jwt''opaque'Default format for all ATs.
audiencestringissuer URLaud claim for the simple mode.
resourcesRecord<uri, config>{}Per-resource-server overrides.
resources[*].format'opaque' | 'jwt'inherits formatPer-API format.
resources[*].audiencestringresource indicator URIPer-API aud.
resources[*].scopesstring[]all scopesAllowed scopes for this API.
resources[*].expiresInnumberttl.accessTokenPer-API TTL in seconds.

Signing key rotation

When the JWKS is managed with a store file (jwks: { source: 'managed', store: 'storage/authkit-keys.json' }), use the authkit:keys:rotate command to rotate without downtime:

# Generate a new signing key; keep the last 2 (grace period, default)
node ace authkit:keys:rotate

# Keep 3 previous keys (tokens signed with them keep validating)
node ace authkit:keys:rotate --keep=3

# Retire all old keys immediately (only the new key validates)
node ace authkit:keys:rotate --retire

# Preview the rotation plan without touching the file
node ace authkit:keys:rotate --dry-run
FlagDefaultNotes
--keep=N2Number of keys (including the new one) to keep in JWKS.
--retireKeep only the new key; retire all previous immediately.
--dry-runPrint the plan without writing.

The new key is placed first (the provider signs with the first matching key). Older keys remain in the JWKS so tokens signed before the rotation continue to validate during the grace period. The rotation emits a keys.rotated audit event.

Rotating without a grace period (--retire) immediately invalidates all tokens signed with previous keys — active sessions will require re-authentication. Use --keep=2 (default) in production.

DPoP

DPoP (Demonstrating Proof of Possession, RFC 9449) binds access/refresh tokens to a client-held key pair, so a stolen bearer token is useless without the matching private key. Off by default; enable it in defineConfig:

config/authkit.ts
dpop: { enabled: true },
FieldTypeDefaultNotes
enabledbooleanfalseTurns on oidc-provider's dPoP feature.

When enabled, the discovery document advertises dpop_signing_alg_values_supported. A token request carrying a valid DPoP proof header yields a sender-constrained token (token_type: DPoP) whose confirmation key (cnf.jkt) is surfaced via introspection. Requests without a DPoP proof keep working unchanged.

Client-side proof generation

The @adonis-agora/authkit-client package ships DPoP proof helpers (jose ES256). Generate a key pair once (persist the exported JWKs in the session), then mint a fresh proof per request:

import { generateDpopKeyPair, createDpopProof } from '@adonis-agora/authkit-client'

// once per client/session
const key = await generateDpopKeyPair()

// per token request
const proof = await createDpopProof({
  key,
  htm: 'POST',
  htu: `${issuer}/token`,
})
await fetch(`${issuer}/token`, { method: 'POST', headers: { DPoP: proof }, body })

// per resource request (binds to the access token via the `ath` claim)
const apiProof = await createDpopProof({
  key,
  htm: 'GET',
  htu: 'https://api.example.com/me',
  accessToken,
})

The proof is a dpop+jwt JWT carrying the public JWK in its header and jti/htm/htu/iat claims (plus ath when an access token is supplied, and nonce when the server demands one). dpopJwkThumbprint(key) returns the jkt the server stamps into cnf.jkt.

The AuthKit client resolvers also accept DPoP tokens transparently — the cnf binding travels in the introspection result, so server-side validation works whether or not the resource server checks the proof itself.

PAR

Pushed Authorization Requests (PAR, RFC 9126) let a client POST its authorization parameters to the IdP first and receive an opaque request_uri to use at /auth, keeping parameters off the front channel. Off by default:

config/authkit.ts
par: {
  enabled: true,
  requirePushedAuthorizationRequests: false, // set true to force every authorize via PAR
},
FieldTypeDefaultNotes
enabledbooleanfalseExposes pushed_authorization_request_endpoint (${issuer}/request).
requirePushedAuthorizationRequestsbooleanfalseRejects inline authorize params; every request must come via a request_uri.
# 1. Push the request
curl -u app:secret -d 'client_id=app&response_type=code&redirect_uri=...&scope=openid&code_challenge=...&code_challenge_method=S256' \
  ${ISSUER}/request
# -> 201 { "request_uri": "urn:ietf:params:oauth:request_uri:…", "expires_in": 60 }

# 2. Authorize with the request_uri
${ISSUER}/auth?client_id=app&request_uri=urn:ietf:params:oauth:request_uri:…

Step-up authentication

Step-up auth lets a client require MFA for a specific request via acr_values, even if the account has MFA configured as optional. It is built on the existing MFA interaction rather than a separate ceremony.

config/authkit.ts
stepUp: {
  acrValues: ['urn:authkit:mfa'], // advertised in acr_values_supported
  mfaAcr: 'urn:authkit:mfa',       // the acr that forces MFA (default)
},
FieldTypeDefaultNotes
acrValuesstring[][] (+ mfaAcr)acr values advertised as supported in discovery. The mfaAcr is always included.
mfaAcrstring'urn:authkit:mfa'When a request's acr_values contains this, MFA is required.

Behaviour when a client requests the mfaAcr in acr_values:

  • Account has MFA enrolled → the second factor is required for that login. On success the id_token carries the real acr (mfaAcr) and amr (['mfa', <method>], e.g. totp, recovery, webauthn).
  • Account has no MFA enrolled → the login is blocked for that request, with a message to configure MFA in the account console (there is no second factor to challenge).

A step-up request always ignores the trusted-device cookie. The client asked for proof on this request; a cookie is evidence about a past ceremony, so honouring it would let the client believe it received a fresh factor when it did not — and the acr stamped into the id_token would be a lie. See Trusted devices — step-up always ignores the cookie.

On this page