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:
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:
| Bucket | Keyed by | Budget | Guards |
|---|---|---|---|
login | IP | 10 / min | The interactive credential routes — login, sign-up, forgot, reset, MFA, magic-link request. |
otpLogin | IP | 5 / min | POST /auth/interaction/:uid/otp-verify. Tighter than login because a six-digit code is guessable in a way a token is not. |
sudo | IP | 10 / min | The 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. |
introspection | IP or bearer secret | 60 / min | /authkit/pat/introspect. |
adminIp | IP | 30 / min | The 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.
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
},
},| Option | Type | Default | Notes |
|---|---|---|---|
verify | (input) => Promise<boolean> | — | Required. Return true = human, false = reject. |
on | ('login' | 'signup' | 'reset')[] | ['login','signup'] | Which flows to protect. |
widget.scriptUrl | string | — | External script loaded async on the affected pages. |
widget.html | string | — | Container injected raw into the form. |
tokenFields | string[] | ['cf-turnstile-response', 'h-captcha-response', 'g-recaptcha-response', 'authkit-bot-token'] | Body fields checked in order for the token. |
timeoutMs | number | 5000 | Abort 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"]
}| Field | Type | Notes |
|---|---|---|
enabled | boolean | true = 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:
botProtection.verifyabsent in config — the feature does not exist; any stored setting is ignored.auth_settingstable absent or error — falls back to config (zero breaking change; fail-safe).- Setting present,
enabled: false— bot protection is off at runtime regardless of config. - Setting present,
enabled: true,onprovided — usesonfrom the setting,verify/widget/tokenFields/timeoutMsfrom config. - Setting present,
enabled: true,onabsent — usesonfrom 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).
defineConfig({
// …
registration: { enabled: true }, // static default; overridable at runtime
})Setting key: registration
{ "enabled": false }| Field | Type | Notes |
|---|---|---|
enabled | boolean | false 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/signuprejects 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:
auth_settingstable absent or error → usesconfig.registration.enabled(fail-safe).- 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.
defineConfig({
// …
login: { requireVerifiedEmail: false }, // static default; overridable at runtime
})Setting key: require_verified_email
{ "enabled": true }| Field | Type | Notes |
|---|---|---|
enabled | boolean | true blocks logins for accounts whose email is unverified. |
Precedence rules:
auth_settingstable absent or error → usesconfig.login.requireVerifiedEmail(fail-safe).- 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."
}| Field | Type | Notes |
|---|---|---|
enabled | boolean | true activates maintenance mode. |
message | string? | 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.
POSTsubmissions 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.rolesbypass 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:
auth_settingstable absent or error →{ enabled: false }(system is up; fail-safe).- 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
}| Field | Type | Notes |
|---|---|---|
password | boolean? | Show the email + password form. Default: true. |
magicLink | boolean? | Show the magic-link button. Default: true when the store and mail capability support it. |
passkey | boolean? | Show passkey-first sign-in. Default: true when WebAuthn is configured. |
social | string[]? | 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. |
forgotPassword | boolean? | 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. |
passkeyAutofill | boolean? | 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:
auth_settingstable absent or DB error → config-derived defaults (fail-safe).- Setting present, field set → the field's value overrides the default.
- Setting present, field absent → the config-derived default for that field is used.
forgotPassword→ computed as(setting.forgotPassword ?? true) && passwordEnabled. Turning offpasswordsilently turns offforgotPasswordas well, regardless of the stored value.social→ intersection of the stored list withconfig.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 theGET /auth/forgot-password+POST /auth/forgot-passwordendpoints 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.socialfiltered — only the listed provider buttons are rendered; buttons for providers outside the list are not rendered.
authkit:doctor checks:
- Shape invalid (e.g.
socialis not an array,passwordis not a boolean) →warn. - All methods effectively off (all-off scenario) →
warn. - Provider in the stored
sociallist not present inconfig.social.providers→warn.
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).
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
}| Field | Type | Default | Notes |
|---|---|---|---|
enabled | boolean | true | false disables the gate entirely — no confirmation required. |
graceMinutes | number | 15 | Minutes 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
}| Field | Type | Default | Notes |
|---|---|---|---|
enabled | boolean | true | false disables OTP lockout. No-op if @adonisjs/limiter is not installed. |
maxAttempts | number | 5 | Consecutive failures before the factor is locked. Minimum: 1. |
unlockTtlHours | number | 24 | How 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/:tokenThe 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:
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:
| Event | When |
|---|---|
otp.locked | Factor locked after maxAttempts consecutive failures. metadata.maxAttempts contains the threshold. |
otp.unlocked | Factor unlocked via the email link. |
otp.unlock_failed | Unlock 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.
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
| Hook | Payload | When 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:
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.
origin — and why emailed links ignore the request
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.
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:
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.
| Option | Type | Default | Notes |
|---|---|---|---|
format | 'opaque' | 'jwt' | 'opaque' | Default format for all ATs. |
audience | string | issuer URL | aud claim for the simple mode. |
resources | Record<uri, config> | {} | Per-resource-server overrides. |
resources[*].format | 'opaque' | 'jwt' | inherits format | Per-API format. |
resources[*].audience | string | resource indicator URI | Per-API aud. |
resources[*].scopes | string[] | all scopes | Allowed scopes for this API. |
resources[*].expiresIn | number | ttl.accessToken | Per-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| Flag | Default | Notes |
|---|---|---|
--keep=N | 2 | Number of keys (including the new one) to keep in JWKS. |
--retire | — | Keep only the new key; retire all previous immediately. |
--dry-run | — | Print 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:
dpop: { enabled: true },| Field | Type | Default | Notes |
|---|---|---|---|
enabled | boolean | false | Turns 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:
par: {
enabled: true,
requirePushedAuthorizationRequests: false, // set true to force every authorize via PAR
},| Field | Type | Default | Notes |
|---|---|---|---|
enabled | boolean | false | Exposes pushed_authorization_request_endpoint (${issuer}/request). |
requirePushedAuthorizationRequests | boolean | false | Rejects 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.
stepUp: {
acrValues: ['urn:authkit:mfa'], // advertised in acr_values_supported
mfaAcr: 'urn:authkit:mfa', // the acr that forces MFA (default)
},| Field | Type | Default | Notes |
|---|---|---|---|
acrValues | string[] | [] (+ mfaAcr) | acr values advertised as supported in discovery. The mfaAcr is always included. |
mfaAcr | string | '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) andamr(['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.
Customizing auth
Task-oriented recipes for bending AuthKit's behaviour — route guards, role gating, where roles come from, user mapping, custom screens, emails, account stores, events, and token resolution.
React (Frontend)
useAuth, gating components, permission checks, headless hooks, and the passkey tiers of @adonis-agora/authkit-react.