Organizations
Multi-tenancy — organizations, members, invitations, and per-org token claims.
Organizations add multi-tenancy to your IdP: users belong to one or more organizations,
can switch their active organization, and receive per-org claims (org_id, org_slug,
org_role) in every token. The feature is capability-probed: AuthKit activates it
automatically when the three required tables are present in the database — no config flag
needed unless you want to tune the defaults.
Database schema
Created automatically. With schema auto-management on (the default), AuthKit creates the
three organization tables on boot — nothing to do. If you disabled it
(schema: { autoManage: false }), call ensureAuthkitSchema(this.db) in a migration, or
create the tables yourself. For reference, the column names the Lucid store expects:
// auth_organizations
schema.createTable('auth_organizations', (table) => {
table.string('id').primary()
table.string('name').notNullable()
table.string('slug').notNullable().unique()
table.string('logo_url').nullable()
table.json('metadata').nullable()
table.timestamps(true, true)
})
// auth_organization_members
schema.createTable('auth_organization_members', (table) => {
table.string('id').primary()
table.string('organization_id').notNullable()
.references('id').inTable('auth_organizations').onDelete('CASCADE')
table.string('account_id').notNullable()
table.string('role').notNullable().defaultTo('member')
table.timestamps(true, true)
table.unique(['organization_id', 'account_id'])
})
// auth_organization_invitations
schema.createTable('auth_organization_invitations', (table) => {
table.string('id').primary()
table.string('organization_id').notNullable()
.references('id').inTable('auth_organizations').onDelete('CASCADE')
table.string('email').notNullable()
table.string('role').notNullable().defaultTo('member')
table.string('token_hash').notNullable().unique()
table.string('invited_by').notNullable() // accountId of the inviter
table.timestamp('expires_at').notNullable()
table.timestamp('accepted_at').nullable()
table.timestamps(true, true)
})Configuration
Configure organizations in defineConfig:
defineConfig({
// …
organizations: {
// enabled: true, // default: auto (on when tables exist)
claimStrategy: 'active', // emit claims from the session's active org (default)
allowSelfCreate: true, // signed-in users may create their own org
roles: ['owner', 'admin', 'analyst'],
invitationTtlHours: 72,
},
})| Option | Type | Default | Notes |
|---|---|---|---|
enabled | boolean | auto | Intent signal for authkit:doctor: with true it warns when the store has no OrganizationsCapability. It does not gate routes — those are mounted from the capability (see Lucid store models below). |
claimStrategy | 'active' | 'active' | Emit claims from the current active org cookie. The only supported strategy. |
allowSelfCreate | boolean | false | POST /account/orgs is allowed (the creator becomes owner). |
roles | string[] | ['owner', 'admin', 'member'] | Org role catalog. owner is always kept. |
invitationTtlHours | number | 168 | Invitation lifetime. |
The effective policy is resolved org setting → global setting → config.organizations →
library default, and the member-facing /account/orgs screens, the admin console and the
Admin API all read it. Declaring organizations in defineConfig locks the
organizations_policy setting (see Config locks), so the policy
fields above are the policy; leave organizations out of the config to manage the policy
from the console instead.
Without the config key, the policy lives in the organizations_policy runtime setting —
see Runtime Settings:
node ace authkit:settings:set organizations_policy \
'{"allowSelfCreate":false,"invitationTtlHours":72,"roles":["owner","admin","member"]}'Lucid store models
The three organization tables are owned by the library — ensureAuthkitSchema creates
and evolves them — so you don't write models for them. The built-in models are used by
default: just build the store and the OrganizationsCapability is there.
import { lucidAccountStore } from '@adonis-agora/authkit-server'
accountStore: lucidAccountStore(AuthUser, {
// …your existing options (mfaIssuer, password, …)…
})organizationModels: true is the explicit spelling of that same default. To turn the
capability off — for example because you relied on supportsOrganizations === false — pass
organizationModels: false.
If your auth tables live in their own connection or schema (static connection = 'auth'),
pass the three model classes explicitly — the built-in models don't declare a connection:
import { lucidAccountStore } from '@adonis-agora/authkit-server'
import AuthOrganization from '#models/auth_organization'
import AuthOrganizationMember from '#models/auth_organization_member'
import AuthOrganizationInvitation from '#models/auth_organization_invitation'
accountStore: lucidAccountStore(AuthUser, {
// …your existing options (mfaIssuer, password, …)…
organizationModels: {
OrgModel: AuthOrganization,
MemberModel: AuthOrganizationMember,
InvitationModel: AuthOrganizationInvitation,
},
})The store returns an OrganizationsCapability that the host kit uses automatically. The
/account/orgs screen is mounted by default; without this capability it would answer 403,
which is exactly what used to happen when the host forgot organizationModels.
Token claims
When a user has an active organization (set via the signed cookie from /account/orgs),
these claims are added to every id_token, userinfo response, and JWT access token:
| Claim | Type | Notes |
|---|---|---|
org_id | string | The active organization's id. |
org_slug | string | The active organization's slug. |
org_role | string | The member's role in the active org (e.g. 'owner'). |
Org claims are only emitted when the user has an active org. A new login or token refresh is required to see claims for a newly joined organization.
Claims follow the store, not the consent
The active org is captured on the Grant at consent time and every refresh re-emits it —
for as long as the refresh token lives (30 days by default). That capture is only a
snapshot, so at every mint (code exchange, refresh, userinfo) AuthKit checks it against
the account store before emitting anything:
- the account must still be a member of the org (
getOrgMembership); - the org must still exist (
findOrgById); org_roleis the member's current role andorg_slugthe org's current slug — never the values captured at consent.
If the check fails, the token is still issued, just without org_*. resolveTokenRoles
receives the verified activeOrg (or null). A store without findOrgById and
getOrgMembership (the OrganizationsCapability, or a partial one) has nothing to check
against, so it never emits org claims.
A store error is not swallowed: the token request fails rather than silently dropping the org or re-emitting the stale one.
Removing a member revokes access immediately
Checking at mint time only takes effect at the next refresh. So when a membership ends,
AuthKit also revokes the grants that carry that org for that account — their access and
refresh tokens included — and records a per-sub revocation so cookie-based clients drop
the session on their next request. This happens on every removal path:
| Path | Revoked |
|---|---|
Leave (POST /account/orgs/:id/leave, POST /account/api/orgs/:id/leave) | the leaver's grants for that org |
Remove member (/account/orgs, /account/api/orgs, admin console, Admin API, SDK) | the removed member's grants for that org |
| Delete org (admin console, Admin API, SDK) | every account's grants for that org |
Grants for other orgs, or without an org, are left alone. The relying party sees
invalid_grant on the next refresh and sends the user back through /authorize; the new
grant comes out without the org. Revocation is best-effort: if it fails (for example, the
adapter is down) the membership change still succeeds, the failure is logged at error,
and the mint-time check drops the claim on the next refresh. Adapters that cannot enumerate
grants (no list) rely on the mint-time check alone.
To revoke by hand, use AdminSessionsService#revokeOrgGrants(orgId, accountId?).
A role change does not revoke anything: the next refresh simply carries the new role.
Consuming the tenant in a relying party
On the client side (@adonis-agora/authkit-client) the org claims are promoted to
first-class fields on the resolved Identity — you do not have to reach into identity.raw:
const identity = await ctx.auth.getIdentity()
identity.orgId // 'org-9' | null
identity.orgSlug // 'acme' | null
identity.orgRole // 'owner' | nullThe derivation also accepts the tenant claims other IdPs emit (active_organization_id,
organization_id, tenant_id, tid), so an app behind a third-party IdP (see
BYO IdP) reads the same field. active_organization_id wins when several
are present.
That same value is published to @adonis-agora/context as tenantId, which is what lets
@adonis-agora/authz scope roles and queries per tenant automatically:
import { tenantFromContext } from '@adonis-agora/authz'
defineConfig({ resolveTenant: tenantFromContext })Requiring an active organization
Authenticated is not the same as inside a tenant. A logged-in user with no active org
reaches your controller with an undefined tenant — and a tenant-scoped authz check then
falls back to the global scope, which is the worst place to land by accident. The
requireOrg middleware closes that:
export const middleware = router.named({
auth: () => import('@adonis-agora/authkit-client/auth_middleware'),
requireOrg: () => import('@adonis-agora/authkit-client/require_org_middleware'),
})// Web: redirects to the org picker (default `/account/orgs`).
router.group(() => { /* … */ }).use([middleware.auth(), middleware.requireOrg()])
// API: 403 `no_active_organization` instead of a redirect.
router.group(() => { /* … */ }).use([middleware.auth(), middleware.requireOrg({ mode: 'api' })])
// Restrict to a known set of tenants.
router.get('/internal', [C, 'x']).use(middleware.requireOrg({ oneOf: ['org-9'] }))| Option | Type | Default | Notes |
|---|---|---|---|
redirectTo | string | /account/orgs | Where web mode sends a user with no active org. |
mode | 'web' | 'api' | 'web' | api responds 403 with a JSON body. |
oneOf | string[] | — | Only these org ids pass. |
It is fail-closed: no session, or no active org, means no pass.
Account console — /account/orgs
Every authenticated user has access to /account/orgs:
| Route | Action |
|---|---|
GET /account/orgs | List the user's organizations; switch the active one |
POST /account/orgs | Create a new organization (requires allowSelfCreate: true) |
POST /account/orgs/deactivate | Clear the active organization cookie |
POST /account/orgs/:id/activate | Set the active organization |
POST /account/orgs/:id/leave | Leave an organization (blocked when last owner) |
POST /account/orgs/:id/invite | Send an email invitation to a new member |
POST /account/orgs/:id/members/:accountId/remove | Remove a member |
POST /account/orgs/:id/invitations/:invId/revoke | Cancel a pending invitation |
Every one of those writes has a JSON mirror under /account/api/orgs/* — same guards,
same effective policy, JSON errors instead of a redirect — for hosts that render their own
organization screens. Role changes, which the console only offers through the admin path, are
there too. See Host-owned account screens.
Invitation flow
Invitations are sent by email (mail.onOrgInvitation) and carry a short-lived signed token
(SHA-256 hash stored in the DB, TTL from organizations_policy.invitationTtlHours). The accept URLs are:
GET /account/orgs/invitations/:token/accept → accept form (no session required)
POST /account/orgs/invitations/:token/accept → accept the invitationThe token is validated against the hash, TTL, and the authenticated account's email before creating the membership.
Customize the invitation email
Wiring mail.onOrgInvitation is optional. Without it the host kit sends the invitation
itself through the host's default @adonisjs/mail mailer, branded and translated like every
other AuthKit email. Provide the hook only to take the message over:
mail: {
onOrgInvitation: async ({ email, orgName, orgSlug, role, acceptUrl }) => {
await mailer.send((m) =>
m.from(env.get('EMAIL_FROM')).to(email)
.subject(`You have been invited to ${orgName}`)
.text(`Accept your invitation as ${role}: ${acceptUrl}`)
)
},
}Reacting to organization events
Every organization operation is audited, and each audit event carries orgId as a
first-class field. Wire events.onEvent (or a webhook) to keep your own tables in sync
without depending on any other Agora library — see
Events → Provisioning your own database:
events: {
onEvent: async (event) => {
if (event.type === 'organization.created') {
await Company.create({ authOrgId: event.orgId! })
}
},
}If you do use @adonis-agora/authz, its event-driven provisioning subscribes to the same
events off the diagnostics bus and can assign tenant-scoped roles directly:
await defineAuthzProvisioning({
store,
on: {
'organization.created': (event, store) =>
store.assignRole({ type: 'user', id: event.accountId! }, 'org:owner', {
tenantId: event.orgId!,
}),
},
})Read event.orgId from the top level. The diagnostics bus strips metadata (it may contain
PII), so event.metadata.orgId is undefined on that path.
Governance invariants
- An organization always has at least one owner. Removing or demoting the last owner is
rejected with
last_owner. - LGPD cascade: deleting an account removes all its memberships and pending invitations. The org itself is not deleted (the other members still exist).
- Data export includes the user's memberships and pending invitations (see Compliance).
Admin console — /admin/orgs
When the admin console is enabled, an Organizations section appears under /admin/orgs:
| Route | Action |
|---|---|
GET /admin/orgs | List all organizations (paginated) |
POST /admin/orgs | Create an organization (admin assigns the initial owner) |
GET /admin/orgs/:id | View detail: members + pending invitations |
POST /admin/orgs/:id/delete | Delete the organization and cascade |
POST /admin/orgs/:id/members | Add a member directly (no invitation) |
POST /admin/orgs/:id/members/:accountId/remove | Remove a member |
POST /admin/orgs/:id/invitations/:invId/revoke | Revoke an invitation |
Admin REST API
All organization operations are available under /api/authkit/v1/organizations. Requires
the Admin REST API to be enabled and a Bearer API key.
| Method | Path | Body / Query | Notes |
|---|---|---|---|
GET | /organizations | — | List all organizations. |
POST | /organizations | { name, slug, ownerAccountId, logoUrl? } | Create; automatically adds the owner as a member. |
GET | /organizations/:id | — | Detail with members[] and pendingInvitations[]. |
PATCH | /organizations/:id | { name?, logoUrl? } | Update metadata. |
DELETE | /organizations/:id | — | Delete and cascade members/invitations. |
POST | /organizations/:id/members | { accountId, role } | Add a member directly. |
DELETE | /organizations/:id/members/:accountId | — | Remove a member. |
PATCH | /organizations/:id/members/:accountId | { role } | Update a member's role. |
POST | /organizations/:id/invitations | { email, role } | Create and send an invitation. |
DELETE | /organizations/:id/invitations/:invitationId | — | Revoke an invitation. |
# Create an organization
curl -X POST https://idp.example.com/api/authkit/v1/organizations \
-H "Authorization: Bearer $KEY" \
-H "Content-Type: application/json" \
-d '{ "name": "Acme Corp", "slug": "acme", "ownerAccountId": "acc-42" }'
# Invite a member
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" }'Backend SDK
const authkit = await createAuthkit({ mode: 'remote', baseUrl, apiKey })
// List
const { data: orgs } = await authkit.organizations.list()
// Create
const org = await authkit.organizations.create({
name: 'Acme Corp',
slug: 'acme',
ownerAccountId: 'acc-42',
})
// Detail (includes members + pending invitations)
const detail = await authkit.organizations.get(org.id)
detail.members // AuthkitOrgMember[]
detail.pendingInvitations // AuthkitOrgInvitation[]
// Members
await authkit.organizations.members.add(org.id, { accountId: 'acc-7', role: 'member' })
await authkit.organizations.members.updateRole(org.id, 'acc-7', 'admin')
await authkit.organizations.members.remove(org.id, 'acc-7')
// Invitations
const inv = await authkit.organizations.invitations.create(org.id, {
email: 'bob@acme.com',
role: 'member',
})
await authkit.organizations.invitations.revoke(org.id, inv.id)
// Delete
await authkit.organizations.delete(org.id)See SDK for the full type table.
React
@adonis-agora/authkit-react ships four hooks and two pre-built components for
organizations. They fetch data from the JSON endpoints the host kit exposes (/account/orgs/json,
/account/orgs/invitations/json, /account/orgs/:id/json) and POST mutations via the
standard account routes.
Hooks
import {
useOrganizations,
useOrganization,
useSwitchOrganization,
useOrgInvitations,
} from '@adonis-agora/authkit-react'
// List the current user's organizations + pending inbound invitations
const orgs = useOrganizations()
// orgs.data: OrgEntry[] (id, name, slug, logoUrl, role, isActive)
// orgs.invitations: OrgInvitationEntry[]
// orgs.loading, orgs.error
// orgs.actions.create({ name, slug }), orgs.actions.leave(orgId), orgs.actions.accept(token)
// Detail of one organization (members + active invitations)
const org = useOrganization(orgId)
// org.data: ActiveOrgDetail (includes members: OrgMemberEntry[])
// org.actions.invite({ email, role }), org.actions.removeMember(accountId), org.actions.revokeInvitation(invId)
// Switch the active organization
const switcher = useSwitchOrganization()
// switcher.activate(orgId), switcher.deactivate()
// switcher.loading
// Pending invitations for the current user
const invitations = useOrgInvitations()
// invitations.data: OrgInvitationEntry[]
// invitations.actions.accept(token), invitations.actions.refetch()Components
import {
OrganizationSwitcher,
OrganizationProfile,
} from '@adonis-agora/authkit-react'
// Dropdown to switch the active organization; optionally shows a "Create org" button
<OrganizationSwitcher
allowCreate={false} // default; set true when the organizations_policy.allowSelfCreate setting is on
createLabel="New organization"
className="my-custom-class"
/>
// Full management card: members, invitations, leave/invite controls
<OrganizationProfile
orgId={activeOrgId}
className="my-custom-class"
/>Both components use the same --authkit-* CSS variables as the rest of the component kit.
See React Components for the theming reference.
Audit events
Every organization operation emits an audit event:
| Event | When |
|---|---|
organization.created | An organization is created. |
organization.updated | Organization metadata is updated. |
organization.deleted | An organization is deleted. |
organization.member_added | A member is added directly. |
organization.member_removed | A member is removed. |
organization.member_role_updated | A member's role is changed. |
organization.switched | A user switches their active organization. |
organization.deactivated | A user deactivates their active org. |
organization.invitation_sent | An email invitation is created. |
organization.invitation_accepted | An invitation is accepted. |
organization.invitation_revoked | An invitation is revoked. |