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):
defineConfig({
// ...
adminApi: {
enabled: true,
apiKeys: [env.get('AUTHKIT_ADMIN_API_KEY')], // one or more high-entropy keys
},
})registerAuthHost(router, {
mountPath: '/oidc',
adminApi: true, // mirror adminApi.enabled here
})| Field | Type | Default | Notes |
|---|---|---|---|
enabled | boolean | false | Mounts the /api/authkit/v1/* group behind the API-key guard. |
apiKeys | string[] | [] | 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:
// 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:
registerAuthHost(router, {
mountPath: '/oidc',
admin: { prefix: '/auth/admin' },
adminApi: { prefix: '/authkit/api' },
})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: false→404(the API's existence is not leaked).- Missing/wrong key (or no keys configured) →
401with{ "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
| Method | Path | Body / Query | Notes |
|---|---|---|---|
GET | /users | ?search&page&size | Paginated list; search by email substring. page is 1-based (default 1); size is the page size (default 20, capped at 200). |
GET | /users/:id | — | Single 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/:id | — | Delete user and cascade (LGPD/GDPR). Returns cascade counts. |
POST | /users/:id/disable | — | Block login. 409 if the store has no status capability. |
POST | /users/:id/enable | — | Re-enable. 409 if unsupported. |
POST | /users/:id/reset-password | — | Issues a reset token and sends the email. 404 if unknown. |
GET | /users/:id/sessions | — | Active IdP sessions + grants (with browser/OS/IP/location context). |
POST | /users/:id/revoke-sessions | — | Revokes 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.
| Method | Path | Body | Notes |
|---|---|---|---|
GET | /clients | — | List persisted clients. |
POST | /clients | { clientId?, redirectUris, postLogoutRedirectUris?, grantTypes, tokenEndpointAuthMethod, backchannelLogoutUri?, backchannelLogoutSessionRequired? } | Create; returns clientSecret (once) for confidential clients. |
GET | /clients/:id | — | Single client (no secret). 404 if unknown. |
PATCH | /clients/:id | same as create body | Update metadata (including backchannel fields); preserves the secret. |
POST | /clients/:id/regenerate-secret | — | New secret (once). 409 for public clients. |
DELETE | /clients/:id | — | Delete 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.
| Method | Path | Body | Response |
|---|---|---|---|
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/:id | — | The 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) — theroleyou 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
| Method | Path | Notes |
|---|---|---|
GET | /stats | IdP 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
| Method | Path | Query | Notes |
|---|---|---|---|
GET | /audit | ?type&subject&page&size | Paginated 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.
| Method | Path | Body / Query | Response |
|---|---|---|---|
GET | /settings | ?organizationId | { data: Setting[], locked: string[] }. data is empty when no rows are stored. |
GET | /settings/:key | ?organizationId | A single setting. 404 not_found when the key has no stored value. |
PUT | /settings/:key | { "value": any }, ?organizationId | The 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.
| Method | Path | Body | Response |
|---|---|---|---|
GET | /keys | — | Age, 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
| Method | Path | Body | Notes |
|---|---|---|---|
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
}