Agora

Admin REST API

The machine-to-machine management API — users, clients, sessions, organizations, settings, signing keys, audit, token verify.

AuthKit ships an opt-in Admin REST API mounted under /api/authkit/v1: a JSON, machine-to-machine management surface meant to be consumed by automation and the backend SDK. It is off by default and authenticates with API keys (no session). It is independent of the Admin Console — you can enable either, both, or neither.

Prefer the SDK. Instead of hand-rolling fetch calls against this API, use @adonis-agora/authkit-sdk — a typed client with the same surface, typed errors (AuthkitApiError), and an embedded driver that skips HTTP entirely when the IdP runs in the same app. This reference documents the underlying wire contract.

Enabling it

Turn it on in both places — defineConfig (the guard) and registerAuthHost (route mounting happens before the lazy config resolves):

config/authkit.ts
defineConfig({
  // ...
  adminApi: {
    enabled: true,
    apiKeys: [env.get('AUTHKIT_ADMIN_API_KEY')], // one or more high-entropy keys
  },
})
start/routes.ts
registerAuthHost(router, {
  mountPath: '/oidc',
  adminApi: true, // mirror adminApi.enabled here
})
FieldTypeDefaultNotes
enabledbooleanfalseMounts the /api/authkit/v1/* group behind the API-key guard.
apiKeysstring[][]Accepted keys for Authorization: Bearer <key>. With none set, every request is 401.

Configurable API prefix

By default the Admin REST API is mounted under /api/authkit/v1. You can move it to any path by passing { prefix: '...' } instead of true in registerAuthHost:

start/routes.ts
// Default — API under /api/authkit/v1 (backward-compatible)
registerAuthHost(router, {
  mountPath: '/oidc',
  adminApi: true,
})

// Custom prefix — API under /authkit/api
registerAuthHost(router, {
  mountPath: '/oidc',
  adminApi: { prefix: '/authkit/api' },
})

The prefix is normalised automatically: it always starts with / and never has a trailing slash. All API routes follow the effective prefix.

When you change the prefix you must pass the same value as apiPrefix to the SDK's remote driver — otherwise every SDK call will 404. See the SDK guide for details.

Acme example — Acme moves the API to /authkit/api to avoid collisions with their own /api namespace:

start/routes.ts
registerAuthHost(router, {
  mountPath: '/oidc',
  admin: { prefix: '/auth/admin' },
  adminApi: { prefix: '/authkit/api' },
})
services/authkit.ts
import { createAuthkit } from '@adonis-agora/authkit-sdk'

const authkit = await createAuthkit({
  mode: 'remote',
  baseUrl: 'https://auth.acme.com',
  apiKey: process.env.AUTHKIT_ADMIN_API_KEY!,
  apiPrefix: '/authkit/api', // must match adminApi.prefix above
})

When prefix is omitted inside the object form (adminApi: {}), the default /api/authkit/v1 is used — identical to adminApi: true.

Authentication

Every request must send a bearer token:

Authorization: Bearer <api-key>

The key is compared against apiKeys in constant time. The guard mirrors the console guard's behaviour:

  • adminApi.enabled: false404 (the API's existence is not leaked).
  • Missing/wrong key (or no keys configured) → 401 with { "error": { "code": "unauthorized", ... } }.

All routes also carry the introspection throttle (the same anti-abuse throttle used by PAT introspection), and every write is audited with metadata.actor: "admin-api".

Error responses use the envelope:

{ "error": { "code": "not_found", "message": "..." } }

Paginated listings (GET /users, GET /audit) answer the { meta, data } envelope Lucid's own .paginate() uses — the shape every @adonis-agora/* package returns:

{
  "meta": { "page": 1, "size": 20, "total": 137 },
  "data": [ /* ... */ ]
}

meta.size is the page size actually applied (already capped at 200), so a client can compute Math.ceil(meta.total / meta.size) pages without guessing.

Users

MethodPathBody / QueryNotes
GET/users?search&page&sizePaginated list; search by email substring. page is 1-based (default 1); size is the page size (default 20, capped at 200).
GET/users/:idSingle user; 404 if unknown.
POST/users{ email, name?, password?, invite? }Create; with no password (or invite: true) sends a reset/invite email. 409 on duplicate email.
PATCH/users/:id{ globalRoles?, name?, avatarUrl? }Update roles (via setGlobalRoles) and/or profile.
DELETE/users/:idDelete user and cascade (LGPD/GDPR). Returns cascade counts.
POST/users/:id/disableBlock login. 409 if the store has no status capability.
POST/users/:id/enableRe-enable. 409 if unsupported.
POST/users/:id/reset-passwordIssues a reset token and sends the email. 404 if unknown.
GET/users/:id/sessionsActive IdP sessions + grants (with browser/OS/IP/location context).
POST/users/:id/revoke-sessionsRevokes all sessions/grants (cascades token invalidation).

Create a user

curl -X POST https://idp.example.com/api/authkit/v1/users \
  -H "Authorization: Bearer $KEY" \
  -H "Content-Type: application/json" \
  -d '{ "email": "jane@example.com", "name": "Jane", "invite": true }'
{
  "id": "acc-42",
  "email": "jane@example.com",
  "name": "Jane",
  "avatarUrl": null,
  "globalRoles": [],
  "disabled": false,
  "invited": true
}

Update roles

curl -X PATCH https://idp.example.com/api/authkit/v1/users/acc-42 \
  -H "Authorization: Bearer $KEY" \
  -H "Content-Type: application/json" \
  -d '{ "globalRoles": ["ADMIN", "STAFF"] }'

List sessions

curl https://idp.example.com/api/authkit/v1/users/acc-42/sessions \
  -H "Authorization: Bearer $KEY"
{
  "canList": true,
  "sessions": [{ "id": "sess-…", "accountId": "acc-42", "loginTs": 1700000000, "amr": ["pwd"] }],
  "grants": [{ "id": "grant-…", "accountId": "acc-42", "clientId": "web", "accessTokens": 2, "refreshTokens": 1 }]
}

Clients

This is the canonical path for managing OIDC clients. The static clients field in defineConfig is deprecated — create all clients here (or in the admin console) so they are stored in the adapter/DB and manageable without a redeploy.

Reuses the same AdminClientsService as the console. The secret is returned once on create and regenerate (it is never recoverable afterwards). Enumeration requires the adapter's optional list capability — index returns canList: false with an empty list otherwise.

MethodPathBodyNotes
GET/clientsList persisted clients.
POST/clients{ clientId?, redirectUris, postLogoutRedirectUris?, grantTypes, tokenEndpointAuthMethod, backchannelLogoutUri?, backchannelLogoutSessionRequired? }Create; returns clientSecret (once) for confidential clients.
GET/clients/:idSingle client (no secret). 404 if unknown.
PATCH/clients/:idsame as create bodyUpdate metadata (including backchannel fields); preserves the secret.
POST/clients/:id/regenerate-secretNew secret (once). 409 for public clients.
DELETE/clients/:idDelete and evict the cache.
curl -X POST https://idp.example.com/api/authkit/v1/clients \
  -H "Authorization: Bearer $KEY" \
  -H "Content-Type: application/json" \
  -d '{
        "redirectUris": ["https://app.example.com/callback"],
        "grantTypes": ["authorization_code", "refresh_token"],
        "tokenEndpointAuthMethod": "client_secret_basic"
      }'
{ "clientId": "kx9…", "clientSecret": "s3cr3t-shown-once" }

Organizations

Full CRUD for organizations, their members, and their invitations. Every route here is capability-gated: when the account store does not implement the organizations capability the whole group answers 404 with { "error": { "code": "capability_unsupported", … } } — the same probe described in Account Store. See Organizations for the schema, the role catalogue, and the invitation flow.

MethodPathBodyResponse
GET/organizations{ data: [{ id, name, slug, logoUrl, metadata, createdAt, memberCount }] }
POST/organizations{ name, slug, ownerAccountId, logoUrl? }201 with the org. 409 slug_taken when the slug is in use.
GET/organizations/:idThe org plus members[] and pendingInvitations[]. 404 not_found.
PATCH/organizations/:id{ name?, logoUrl? }The updated org. 404 not_found, 409 slug_taken.
DELETE/organizations/:id{ id, deleted: true }. 404 not_found.
POST/organizations/:id/members{ accountId, role? }201 { orgId, accountId, role, added: true }. role defaults to member.
DELETE/organizations/:id/members/:accountId{ orgId, accountId, removed: true }. 409 last_owner.
PATCH/organizations/:id/members/:accountId{ role }{ orgId, accountId, role, updated: true }. 409 last_owner.
POST/organizations/:id/invitations{ email, role? }201 with the invitation. role defaults to member.
DELETE/organizations/:id/invitations/:invitationId{ orgId, invitationId, revoked: true }.

A few error codes are specific to this group and worth handling explicitly:

  • invalid_role (422) — the role you sent is outside the organization's effective role catalogue. The catalogue is resolved from runtime settings first and the static config second, so a role that worked yesterday can be retired without a redeploy.
  • last_owner (409) — returned by member removal and role changes rather than letting an organization end up with no owner.
  • account_not_found / member_not_found / invitation_not_found (404) — the org exists but the sub-resource does not.

Invitation revocation is scoped by organization: the route takes both ids and reports 404 when the invitation belongs to a different org, so an id leaked from elsewhere cannot be used to revoke across tenants.

Because this API authenticates a key rather than a person, an invitation created here records invitedBy: "admin" instead of an account id. The invitation TTL comes from the organizations config on the server, not from the request.

An org DTO looks like this:

{
  "id": "org-1",
  "name": "Acme",
  "slug": "acme",
  "logoUrl": null,
  "metadata": null,
  "createdAt": "2026-06-05T12:00:00.000Z",
  "memberCount": 4
}
# Create an organization — the owner is added as a member automatically
curl -X POST https://idp.example.com/api/authkit/v1/organizations \
  -H "Authorization: Bearer $KEY" \
  -H "Content-Type: application/json" \
  -d '{ "name": "Acme", "slug": "acme", "ownerAccountId": "acc-42" }'

# Invite somebody into it
curl -X POST https://idp.example.com/api/authkit/v1/organizations/org-1/invitations \
  -H "Authorization: Bearer $KEY" \
  -H "Content-Type: application/json" \
  -d '{ "email": "jane@acme.com", "role": "admin" }'
{
  "id": "inv-9",
  "organizationId": "org-1",
  "email": "jane@acme.com",
  "role": "admin",
  "invitedBy": "admin",
  "expiresAt": "2026-06-12T12:00:00.000Z",
  "acceptedAt": null,
  "createdAt": "2026-06-05T12:00:00.000Z"
}

Stats

MethodPathNotes
GET/statsIdP summary metrics.
curl https://idp.example.com/api/authkit/v1/stats \
  -H "Authorization: Bearer $KEY"
{
  "totalUsers": 1024,
  "activeSessions": 87,
  "mau": 312,
  "signInsPerDay": [{ "date": "2026-06-04", "count": 43 }],
  "signUpsPerDay": [{ "date": "2026-06-04", "count": 5 }],
  "signInsTotal": 1290,
  "signUpsTotal": 1024,
  "auditSupported": true,
  "windowDays": 30
}

mau is the count of unique accounts with a login.success event in the last 30 days. activeSessions is null when the adapter cannot enumerate sessions. Series are empty arrays when the audit sink does not support list.

Audit

MethodPathQueryNotes
GET/audit?type&subject&page&sizePaginated audit log. page is 1-based (default 1); size is the page size (default 20, capped at 200).

Requires an audit sink that implements list. When the configured sink is write-only, the endpoint returns 501 { "error": { "code": "not_implemented", … } }.

curl "https://idp.example.com/api/authkit/v1/audit?type=user.created&size=50" \
  -H "Authorization: Bearer $KEY"

The response is the standard paginated envelope: { meta: { page, size, total }, data: [...] }.

Settings

CRUD for entries in the auth_settings table. All routes return 404 with { "error": { "code": "capability_unsupported", ... } } when the table is absent.

MethodPathBody / QueryResponse
GET/settings?organizationId{ data: Setting[], locked: string[] }. data is empty when no rows are stored.
GET/settings/:key?organizationIdA single setting. 404 not_found when the key has no stored value.
PUT/settings/:key{ "value": any }, ?organizationIdThe saved setting. 400 invalid_request when value is missing, 423 setting_locked when the key is pinned in config. Emits settings.updated.
DELETE/settings/:key?organizationId{ key, organizationId, deleted: true }. 423 setting_locked applies here too. Emits settings.updated.

A setting is projected like this:

{
  "key": "bot_protection",
  "organizationId": null,
  "value": { "enabled": true },
  "updatedAt": "2026-06-05T12:00:00.000Z",
  "updatedBy": null,
  "locked": false
}

Locked settings

A key pinned in defineConfig is not editable at runtime. Writes to it are refused with 423 before anything is persisted:

{
  "error": {
    "code": "setting_locked",
    "message": "…",
    "details": { "key": "bot_protection", "lockedBy": "config" }
  }
}

The list route's locked array carries every locked key — including keys that were never written to the table, since a config-only lock has no row. A UI reads that array to grey out the controls rather than letting an operator discover the lock by getting a 423. A locked setting also carries "locked": true and "lockedBy": "config" in its own DTO.

Organization-scoped settings

Some keys can be stored per organization instead of globally. Pass ?organizationId=<id> on any of the four routes to read or write within that org's scope; omit it (or send it empty) and you are operating on the global scope. The org-scopable keys are organizations_policy and roles_catalog — see Organizations.

# Give one org its own role catalogue and invitation lifetime
curl -X PUT "https://idp.example.com/api/authkit/v1/settings/organizations_policy?organizationId=org-1" \
  -H "Authorization: Bearer $KEY" \
  -H "Content-Type: application/json" \
  -d '{ "value": { "roles": ["owner", "admin", "member", "billing"], "invitationTtlHours": 72 } }'

That is what makes the invalid_role response above org-specific: the member routes validate against the catalogue resolved for that organization.

Bot-protection toggle example

# Enable bot protection and override the protected flows
curl -X PUT https://idp.example.com/api/authkit/v1/settings/bot_protection \
  -H "Authorization: Bearer $KEY" \
  -H "Content-Type: application/json" \
  -d '{ "value": { "enabled": true, "on": ["login", "signup", "reset"] } }'
{
  "key": "bot_protection",
  "organizationId": null,
  "value": { "enabled": true, "on": ["login", "signup", "reset"] },
  "updatedAt": "2026-06-05T12:00:00.000Z",
  "updatedBy": null,
  "locked": false
}
# Disable bot protection at runtime
curl -X PUT https://idp.example.com/api/authkit/v1/settings/bot_protection \
  -H "Authorization: Bearer $KEY" \
  -H "Content-Type: application/json" \
  -d '{ "value": { "enabled": false } }'

# Reset to static config (delete the row)
curl -X DELETE https://idp.example.com/api/authkit/v1/settings/bot_protection \
  -H "Authorization: Bearer $KEY"
{ "key": "bot_protection", "organizationId": null, "deleted": true }

See Security — Runtime toggle for the full precedence rules and setting shape.

Registration toggle example

# Close public sign-up 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 (delete the setting — falls back to config)
curl -X DELETE https://idp.acme.com/api/authkit/v1/settings/registration \
  -H "Authorization: Bearer $KEY"

See Security — Registration toggle for the full behaviour.

Require verified email toggle example

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 } }'

See Security — Require verified email toggle.

Maintenance mode example

# Activate maintenance mode with a custom message
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": true, "message": "Upgrading — back shortly." } }'

# Lift maintenance (escape hatch — works without a browser 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 } }'

See Security — Maintenance mode for the full behaviour and lockout-safety guarantees.

Signing keys

Two routes expose the managed signing key: one reads its age against the rotation policy, the other rotates it live. They exist so key rotation is operable from a script or a pipeline — the same operation the console offers behind a session and the CLI offers on the box.

MethodPathBodyResponse
GET/keysAge, effective policy, ETA, and the key list. 501 not_implemented when the JWKS is not a managed keystore.
POST/keys/rotate{ retire?, keep? }The rotation outcome. 501 not_implemented when rotation is unavailable.
curl https://idp.example.com/api/authkit/v1/keys \
  -H "Authorization: Bearer $KEY"
{
  "ageDays": 74,
  "policy": { "enabled": true, "maxAgeDays": 90, "keep": 2 },
  "nextRotationInDays": 16,
  "keys": [
    { "kid": "k-2026-03", "alg": "RS256", "ageDays": 74, "active": true },
    { "kid": "k-2025-12", "alg": "RS256", "ageDays": 165, "active": false }
  ]
}

policy is the effective rotation policy: the key_rotation runtime setting when the settings table is available, and the built-in default (rotation off, maxAgeDays: 90, keep: 2) when it is not. nextRotationInDays is null while the policy is disabled — there is no next rotation to count down to — and never goes below zero once the key is overdue. active: true marks the key currently signing; the rest remain published in the JWKS so tokens they signed still verify.

curl -X POST https://idp.example.com/api/authkit/v1/keys/rotate \
  -H "Authorization: Bearer $KEY" \
  -H "Content-Type: application/json" \
  -d '{ "keep": 2 }'
{
  "rotated": true,
  "newKid": "k-2026-06",
  "retiredKids": [],
  "keptKids": ["k-2026-06", "k-2026-03"]
}

keep is how many keys stay in the published JWKS after the rotation, newest first. It defaults to 2, and any value that is not a number >= 1 falls back to that default — you cannot rotate yourself into a JWKS that verifies nothing. retire: true overrides keep entirely and forces it to 1: only the brand-new key survives, and retiredKids lists everything that was dropped. Rotation applies live — the next token is signed with newKid, no restart — and records a keys.rotated audit event.

retire: true invalidates every token still signed with a dropped key. Under the default (keep: 2, no retire) the previous key stays published, so tokens minted before the rotation keep verifying until they expire. Only retire once that window has passed.

The shared actions

The route handlers are thin. The behaviour lives in two functions exported from @adonis-agora/authkit-server, and the admin console, this REST API, and the SDK's embedded driver all call the same pair — which is why all three report identical numbers:

import { buildKeysStatus, rotateNow } from '@adonis-agora/authkit-server'
import type { ServerKeysStatus } from '@adonis-agora/authkit-server'

// Reads age + effective policy + ETA. `null` means the JWKS is not a managed
// keystore — the REST layer translates that to 501.
const status: ServerKeysStatus | null = await buildKeysStatus(service, settings)

// Validates `keep`/`retire` and performs the rotation, auditing as it goes.
const result = await rotateNow(service, { keep: 2, retire: false })
// { rotated: true, newKid, retiredKids, keptKids }

buildKeysStatus takes the AuthKit server service and an optional settings capability; pass null for the second argument and it falls back to the default policy instead of failing. Reach for these directly when you are building your own operations surface — a health endpoint, a scheduled job, a Slack command — and want the same semantics as the console without going through HTTP. See Security — key rotation.

Token verify

MethodPathBodyNotes
POST/tokens/verify{ token }Generic introspection — handles both PATs and opaque access tokens.

Routes by prefix: pat_… tokens go through the PatStore (same path as /authkit/pat/introspect); anything else is treated as an opaque access token and resolved via the provider's AccessToken.find. An unknown or expired token returns { "active": false }.

curl -X POST https://idp.example.com/api/authkit/v1/tokens/verify \
  -H "Authorization: Bearer $KEY" \
  -H "Content-Type: application/json" \
  -d '{ "token": "pat_…" }'
{
  "active": true,
  "tokenType": "pat",
  "sub": "acc-42",
  "email": "jane@example.com",
  "name": "Jane",
  "roles": ["ADMIN"],
  "scopes": ["read"],
  "audience": null,
  "exp": null
}

On this page