Agora

Runtime Settings

Database-driven runtime configuration — the setting-key catalogue, precedence and config locks, the table, the CLI, and org-scoped keys.

AuthKit's runtime settings let you change operational behaviour without redeploying. Settings are persisted as JSON rows in the auth_settings table and take effect on every request (with a 15-second in-process cache). When the table is absent every setting falls back to the static config — zero breaking change.

Philosophy

LayerSource of truthWhen to use
Runtime setting (DB)auth_settings tableBusiness policy — things an operator changes while the app is running
Static config (defineConfig)config/authkit.tsInfrastructure — secrets, adapters, mail transports, code hooks — plus any policy you want pinned in reviewed, version-controlled code
Library defaultBuilt into AuthKitSensible baseline when no runtime setting is present

Precedence depends on whether the config declares the policy at all, and there are exactly two cases:

  • The config does not declare it — the runtime setting owns the key: setting > lib default. This is the common case, and the one the console is built for.
  • The config declares it — the config wins and the setting is locked out entirely. Reads of that key behave as if no row existed (so every resolver falls through to the config value), and writes are rejected with 423 Locked. Persisting a row for a locked key is impossible, and a row that predates the lock sits inert until the lock is removed.

Declaring registration, authMethods, login.requireVerifiedEmail, lockout, rateLimit, trustedDevices, botProtection, organizations, admin.impersonation or ttl in defineConfig locks the matching setting key. See Config Locks for the full mapping, what an operator sees when a lock holds, and how to unlock.

If the auth_settings table is absent or the DB call fails for any reason, the lib default takes over — the fail-safe is total.

Setting up the table

You don't have to do anything. AuthKit manages its own tables automatically: on boot it creates any of its tables that are missing — including auth_settings — and adds any columns that are missing from tables that already exist (additive only; it never drops or changes existing columns). Runtime settings are therefore active out of the box.

ensureAuthkitSchema covers eight tables:

TableWhat it holds
authkit_oidc_payloadsThe OIDC provider's own storage (grants, sessions, codes) when you use the Lucid adapter
auth_settingsRuntime settings — the subject of this page
auth_password_historyPrevious password hashes, for reuse prevention
auth_mfaEnrolled MFA factors and recovery codes
auth_organizationsOrganizations
auth_organization_membersOrganization membership and per-org roles
auth_organization_invitationsPending organization invitations
auth_session_revocationsRevocation markers for back-channel logout and single-session enforcement

There is a ninth table, authkit_keystore, that is not part of that set. The managed keystore loads while the config is being resolved — before boot, and therefore before schema management runs — so it cannot depend on the table existing. The Lucid keystore vault creates it on demand, on its first write. Nothing you do is required either way.

Prefer to own your schema? Disable auto-management and run the same logic inside a migration you control:

config/authkit.ts
export default defineConfig({
  // ...
  schema: { autoManage: false },
})
database/migrations/xxxx_authkit_schema.ts
import { BaseSchema } from '@adonisjs/lucid/schema'
import { ensureAuthkitSchema } from '@adonis-agora/authkit-server'

export default class extends BaseSchema {
  async up() {
    await ensureAuthkitSchema(this.db)
  }
}

ensureAuthkitSchema is idempotent and additive — safe to keep in a migration that runs on every deploy. It creates all eight tables listed above; authkit_keystore stays outside it and is still created on demand by the keystore vault. For reference, the shape of auth_settings is:

CREATE TABLE auth_settings (
  key             TEXT NOT NULL,
  organization_id TEXT,                -- NULL = global setting
  value           TEXT NOT NULL,       -- JSON
  updated_at      TIMESTAMP,
  updated_by      TEXT,                -- nullable account id of the admin who wrote it
  UNIQUE (key, organization_id)
);

The organization_id column is what makes a row org-scoped: NULL means the row is global, and a value means it applies to that organization only. Only two keys are ever written with an organization — see Org-scoped settings below. Every other key is global, and the uniqueness constraint on (key, organization_id) is what lets a global row and a per-org row for the same key coexist.

AuthKit detects the table on first access — with a real SELECT rather than a hasTable probe, so a schema reached through a Postgres search_path is found correctly — and memoises the result per RuntimeSettings instance.

Complete setting catalog

Each key with its JSON shape and library default:

KeyShapeLib defaultNotes
bot_protection{ enabled: boolean; on?: ('login'|'signup'|'reset')[] }{ enabled: false }Runtime CAPTCHA toggle. on filters flows; verify always comes from config. Configuring botProtection at all locks this key on — see Config Locks.
registration{ enabled: boolean }{ enabled: true }Close/open public sign-up. Admin-create and org invites bypass this guard.
require_verified_email{ enabled: boolean; graceDays?: number }{ enabled: false, graceDays: 0 }Block unverified accounts. graceDays: days after signup account may still login. Capability-probed (isEmailVerified).
maintenance_mode{ enabled: boolean; message?: string }{ enabled: false }Block login/signup for non-admins. Admin API escape hatch bypasses.
auth_methods{ password?: boolean; magicLink?: boolean; passkey?: boolean; passkeyAutofill?: boolean; social?: string[]; forgotPassword?: boolean }config/capability defaultsFilter login methods. passkeyAutofill enables WebAuthn conditional mediation (default: true when passkey on). Fail-safe all-off reverts to defaults.
email_change{ enabled?: boolean; ttlHours?: number; requirePassword?: boolean }{ enabled: true, ttlHours: 24, requirePassword: true }Verified email-change flow.
security_notifications{ enabled?: boolean; kinds?: string[] }{ enabled: true, kinds: [all 6] }Security-event emails. kinds filters which events trigger.
password_history{ enabled?: boolean; count?: number }{ enabled: false, count: 5 }Reuse prevention. Requires auth_password_history table.
password_expiration{ enabled?: boolean; maxAgeDays?: number }{ enabled: false, maxAgeDays: 90 }Force password rotation. Requires password_changed_at column.
session_policy{ rememberEnabled?: boolean; rememberDays?: number; defaultSessionHours?: number; singleSession?: boolean; idleTimeoutMinutes?: number }{ rememberEnabled: true, rememberDays: 30, defaultSessionHours: 168, singleSession: false, idleTimeoutMinutes: 0 }Session duration, remember-me, single session, idle timeout.
lockout{ enabled?: boolean; maxAttempts?: number; windowSec?: number; baseLockoutSec?: number; maxLockoutSec?: number }{ enabled: true, maxAttempts: 5, windowSec: 900, baseLockoutSec: 60, maxLockoutSec: 3600 }Per-email lockout policy. The only lockout config field is store (which limiter store to use) — and declaring it locks this key.
rate_limit{ login?: { points, duration }; introspection?: { points, duration } }login: { points: 10, duration: '1 min' }; introspection: { points: 60, duration: '1 min' }Rate-limit buckets. See known limitation.
password_policy{ minLength?: number; requireUppercase?: boolean; requireLowercase?: boolean; requireNumbers?: boolean; requireSymbols?: boolean; checkPwned?: boolean; blockCommon?: boolean }{ minLength: 8, checkPwned: false, blockCommon: true, rest false }Password complexity, HIBP check, and offline common-password block.
notifications{ newLoginEmail?: boolean; newDeviceEmail?: boolean }{ newLoginEmail: true, newDeviceEmail: true }New-IP and new-device email alerts.
trusted_devices{ enabled?: boolean; days?: number }{ enabled: true, days: 30 }MFA skip via an encrypted cookie signed with the host's APP_KEY — no extra secret to configure and no new table.
token_ttl{ accessTokenSec?: number; idTokenSec?: number; refreshTokenSec?: number }{ accessTokenSec: 900, idTokenSec: 900, refreshTokenSec: 2592000 }Token lifetimes (seconds). Takes effect immediately via holder. Session TTL is in session_policy.
admin_impersonation{ enabled?: boolean }{ enabled: false }Show impersonation panel in admin console.
organizations_policy{ allowSelfCreate?: boolean; invitationTtlHours?: number; roles?: string[] }{ allowSelfCreate: false, invitationTtlHours: 168, roles: ['owner','admin','member'] }Org policies. owner is always preserved in roles. Org-scopable.
roles_catalog{ roles: { name: string; description?: string }[] }{ roles: [{ name: 'ADMIN', description: 'Full access to the admin console' }] }The catalogue of global roles offered as checkboxes on the console's user page, instead of a free-text field. ADMIN is merged back in defensively whatever you store — it is the gate for the admin console itself — entries without a non-empty name are dropped, and the result is sorted by name. Org-scopable. Managed from the console's Roles page.
key_rotation{ enabled?: boolean; maxAgeDays?: number; keep?: number }{ enabled: false, maxAgeDays: 90, keep: 2 }Age-based rotation of the managed JWKS signing key. When enabled, a background scheduler rotates once the current key is older than maxAgeDays, keeping keep previous keys published so tokens signed with them still verify. Rotation takes a single-flight lock and re-checks the age inside it, so only one instance in a cluster rotates. Applies only when the keystore is managed with a store — see Keystore Vaults.
sudo_mode{ enabled?: boolean; graceMinutes?: number }{ enabled: true, graceMinutes: 15 }Re-authentication gate for sensitive account actions. Emits sudo.confirmed.
otp_lockout{ enabled?: boolean; maxAttempts?: number; unlockTtlHours?: number }{ enabled: true, maxAttempts: 5, unlockTtlHours: 24 }Lock TOTP/recovery factor after N failures. Unlock via email link (GET /auth/otp-unlock/:token). Requires @adonisjs/limiter.
account_expiration{ enabled?: boolean; inactiveDays?: number; warnDays?: number }{ enabled: false, inactiveDays: 365, warnDays: 14 }Block inactive accounts at login. Last activity = last login.success in audit. Warn via mail.onAccountExpirationWarning. Run authkit:accounts:expire-scan. Requires queryable audit sink.

rate_limit limitation

The @adonisjs/limiter middleware is registered at boot time with the static config values and cannot be changed at runtime. The rate_limit setting only affects the AccountLockout code path (per-email limiter calls). For true dynamic IP throttling, change the config and restart the server.

Admin Console

The console does not put every key on one page. Its Settings page carries the six policies an operator changes most often, one card each:

CardSetting key
Login methodsauth_methods
Signupregistration
Email verificationrequire_verified_email
Maintenancemaintenance_mode
Lockoutlockout
Token TTLtoken_ttl

Three more keys are edited from the page that owns the feature rather than from the generic settings list: roles_catalog from Roles, key_rotation from Keys, and organizations_policy (plus a per-org roles_catalog) from the organization drawer. The remaining keys have no console card at all — reach them with the Ace commands or the Admin REST API.

Each card shows the setting key it writes, plus a badge for its state: defined via config when the key is locked — in which case every input is disabled and there is nothing to submit — custom when a row exists in auth_settings, and unsaved while you have pending edits. When the auth_settings table is absent the page replaces itself with a message telling you to run the migration.

Every save emits a settings.updated audit event. Every reset (delete) returns the key to the lib default and also emits settings.updated.

Org-scoped settings

Two keys support per-organization overrides: organizations_policy and roles_catalog. When a scoped row exists for an org, it takes precedence over the global row, which in turn takes precedence over the lib default (org → global → default).

The admin console surfaces this in the organization drawer (Edit org → Settings tab): only these two keys appear there. All other keys are global-only.

Setting via Admin REST API

Append ?organizationId=<id> to scope the read or the write. Omit it and you are operating on the global row:

PUT /api/authkit/v1/settings/organizations_policy?organizationId=org_abc
{ "value": { "allowSelfCreate": true } }

PUT /api/authkit/v1/settings/roles_catalog?organizationId=org_abc
{ "value": { "roles": [{ "name": "ADMIN" }, { "name": "EDITOR", "description": "Publishes content" }] } }

The same query parameter works on GET /settings (list the org's rows) and on DELETE /settings/:key (drop the org override and fall back to the global row).

The SDK's settings methods are global-only — set(key, value) takes no organization argument. Use the Admin REST API directly, or RuntimeSettings in-process, when you need an org-scoped write.

Reading the effective value in backend code

import { RuntimeSettings } from '@adonis-agora/authkit-server'

const settings = new RuntimeSettings(db)
const stored = await settings.getEffective('organizations_policy', orgId)
// resolves the stored row: org-row → global-row → null

getEffective answers "which row applies here", so it returns null when neither scope has one. Applying the library default on top of that is the job of the resolver for the key — call the resolver rather than re-implementing the fallback.

Admin REST API

Every key is managed via the same CRUD endpoints:

# Read all settings
GET /api/authkit/v1/settings

# Read one setting
GET /api/authkit/v1/settings/:key

# Upsert (create or update)
PUT /api/authkit/v1/settings/:key
Content-Type: application/json
{ "value": { ... } }

# Delete (reset to lib default)
DELETE /api/authkit/v1/settings/:key

Example — enable password history:

curl -X PUT https://auth.acme.com/api/authkit/v1/settings/password_history \
  -H "Authorization: Bearer $ADMIN_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "value": { "enabled": true, "count": 10 } }'

Two responses are worth planning for. When the auth_settings table is absent every one of these endpoints answers 404 with capability_unsupported — an honest "this installation does not have runtime settings" rather than a silent success. And when the key is locked by config, PUT and DELETE answer 423 Locked with setting_locked. The listing response carries a locked array beside data so a client can tell which keys will do that before it tries, and each returned row carries its own locked flag (plus lockedBy: "config" when it is set).

SDK

const authkit = await createAuthkit({ mode: 'remote', baseUrl, apiKey })

// Read — an envelope, not a bare value
const setting = await authkit.settings.get('session_policy')
setting.key       // 'session_policy'
setting.value     // { rememberEnabled: true, rememberDays: 30, ... }
setting.updatedAt // ISO string, or null
setting.updatedBy // who wrote it — 'cli' for the Ace commands, null otherwise

// Write
await authkit.settings.set('session_policy', { singleSession: true })

// Delete (reset to the lib default)
await authkit.settings.delete('session_policy') // -> { key, deleted }

// List all
const all = await authkit.settings.list() // -> { data: [...] }

For "who changed this", read the settings.updated audit events rather than updatedBy — the console and the Admin API record the actor in the audit trail and leave the column null; only the Ace commands stamp it, with 'cli'.

Ace CLI

Four commands manage settings from the command line:

# List all settings currently in the DB
node ace authkit:settings:list

# Get a specific setting
node ace authkit:settings:get session_policy

# Upsert a setting (JSON value)
node ace authkit:settings:set session_policy '{"singleSession":true,"rememberDays":7}'

# Delete a setting (reset to lib default)
node ace authkit:settings:unset session_policy

# Machine-readable output
node ace authkit:settings:list --json
node ace authkit:settings:get lockout --json

The set command validates the shape of known keys before writing. Unknown keys are accepted with a warning. All write commands audit settings.updated with updatedBy: 'cli'.

Setting up policy for the first time

There is nothing to seed. Until you write a row, every key resolves to its library default, and those defaults are chosen to be a sane production posture on their own. Set what you want to differ, from the Admin Console or the CLI, and leave the rest alone.

# Example: tighten the lockout policy
node ace authkit:settings:set lockout \
  '{"enabled":true,"maxAttempts":3,"windowSec":600,"baseLockoutSec":120,"maxLockoutSec":7200}'

# Example: enforce a password policy
node ace authkit:settings:set password_policy \
  '{"minLength":12,"requireUppercase":true,"requireNumbers":true,"checkPwned":true}'

# Example: shorten the trusted-device window
node ace authkit:settings:set trusted_devices '{"enabled":true,"days":7}'

Infrastructure that is code or secrets — the limiter store, the password pepper, a legacyVerifier, the botProtection.verify callback — lives in defineConfig and is never managed at runtime.

Two of the commands above only work while the key is unlocked. lockout and trusted_devices both have a defineConfig counterpart, so declaring either field — even purely for infrastructure, as in lockout: { store: 'redis' } — locks the key and the CLI write fails. See Config Locks.

Fail-safes

  • Table absent — every getSetting call returns null; the lib default is used.
  • DB error — same as absent: the resolve function catches the error and returns the lib default.
  • Invalid shape — if the stored JSON does not match the expected shape (e.g. enabled is not a boolean), the resolve function ignores it and falls back to the lib default.
  • All-off for auth_methods — if the config pins and the setting together would leave no login method enabled, AuthKit reverts to the config-derived defaults and logs a warning. An empty login screen is never surfaced.

A config lock rides the same read path: a locked key also reads as null, so the resolver falls through to the config value. It is not a fail-safe, but it is why locking needed no changes to the resolvers.

authkit:doctor checks

The settings-related checks in authkit:doctor are about the seams where a stored value and the config can disagree — the failures that are silent at runtime because every resolver degrades gracefully instead of throwing:

  • The auth_settings table — absent is silently fine, it is an opt-in feature. Present but with no botProtection.verify in the config is a warn: any bot_protection row you stored is an orphan with nothing to enforce it. Otherwise ok, and the message names the connection when the account store uses a named one, which is the fastest way to spot a search_path mix-up.
  • The auth_methods rowwarn when the stored shape is not an object, when it turns every login method off (the runtime fail-safe will quietly revert to the config defaults), or when its social list names a provider that is not in social.providers (the intersection rule filters it out silently).
  • The roles_catalog rowwarn when the shape is not { roles: [...] }, and warn when admin.roles in the config names a role the catalogue does not contain: admin access depends on that role and nobody can be granted it from the console.
  • login.requireVerifiedEmailwarn when it is on but the account store has no isEmailVerified capability, because the check is then a no-op and nobody is blocked.

On this page