Quickstart
Two end-to-end walkthroughs — everything in one app, or a standalone IdP with separate client apps.
AuthKit is topology-agnostic: the same server config powers both an embedded provider and a dedicated one. This page gives you two complete, parallel walkthroughs so you can pick the shape that fits your deployment. Every snippet uses real exported APIs. For deeper dives, follow the links to the feature pages.
- Topology A — everything in one app: a single AdonisJS app hosts the Authorization Server and is one of its own clients (host-is-its-own-client).
- Topology B — separate apps: one app runs only the IdP;
acme-webandacme-adminare pure clients pointing at its issuer.
See Topologies for the conceptual background; switching later is mostly an
infrastructure change — keep the same issuer URL and the relying parties don't need to move.
Once a client is wired, Getting Started shows how to
read the user in your controllers via ctx.auth.
Topology A — everything in one app
One AdonisJS process serves your product and the OIDC endpoints. The app authenticates against itself: it installs both the server kit (to be the IdP) and the client kit (to consume its own issuer). The provider's SSO cookie and the app's own session coexist on the same origin as two separate cookies.
A1. Install both packages
npm install @adonis-agora/authkit-server @adonis-agora/authkit-client
node ace configure @adonis-agora/authkit-server
node ace configure @adonis-agora/authkit-clientconfigure publishes the config files, wires the providers, and registers the bundled ace
commands (including authkit:eject). AuthKit needs a SQL connection (@adonisjs/lucid) for its
adapter and stores.
A2. The account model
Compose an AuthUser from the mixins so the Lucid store has the columns it needs. Two things
the mixins do not provide, and that you must add yourself:
- A primary key. Neither
withAuthUser()norwithCredentials()declares anidcolumn or generates one — without a@beforeCreatehook assigning a real id andstatic selfAssignPrimaryKey = true, Lucid insertsNULLfor theidcolumn, or (with only the hook) silently overwrites the id you just assigned with the database's internal auto-increment rowid right after the insert — either way the account is unreachable by its real id on the very next request. fullName. The built-in signup screen collects a "Name" field, and the Lucid store passes it straight toAuthUser.create()— omit this column and the first signup throwsCannot define "fullName" on "AuthUser" model, since it is not defined as a model property.
import { randomUUID } from 'node:crypto'
import { BaseModel, beforeCreate, column } from '@adonisjs/lucid/orm'
import { compose } from '@adonisjs/core/helpers'
import { withAuthUser, withCredentials } from '@adonis-agora/authkit-server'
export default class AuthUser extends compose(
BaseModel,
withAuthUser(),
withCredentials()
) {
static selfAssignPrimaryKey = true
@column({ isPrimary: true })
declare id: string
@beforeCreate()
static assignUuid(user: AuthUser) {
user.id = randomUUID()
}
@column()
declare fullName: string | null
}node ace configure @adonis-agora/authkit-server also scaffolds the migration for the
auth_users table backing this model, at
database/migrations/<timestamp>_create_auth_users_table.ts. Its columns match every
@column() declared above plus the two mixins' own (email, password,
global_roles) — run node ace migration:run before your first signup. See
Account Store for the full column list, and add your own
migration for any extra column your own @column() declarations introduce later.
import { BaseSchema } from '@adonisjs/lucid/schema'
export default class extends BaseSchema {
protected tableName = 'auth_users'
async up() {
this.schema.createTable(this.tableName, (table) => {
table.string('id').notNullable().primary()
table.string('email').notNullable().unique()
table.string('password').notNullable()
table.json('global_roles').notNullable().defaultTo('[]')
table.timestamp('email_verified_at', { useTz: true }).nullable()
table.string('email_verification_token').nullable()
table.string('password_reset_token').nullable()
table.timestamp('password_reset_expires_at', { useTz: true }).nullable()
table.string('full_name').nullable()
})
}
async down() {
this.schema.dropTable(this.tableName)
}
}A3. Server config
import env from '#start/env'
import AuthUser from '#models/auth_user'
import {
defineConfig,
adapters,
lucidAccountStore,
inertiaRenderer,
} from '@adonis-agora/authkit-server'
const authServerConfig = defineConfig({
issuer: env.get('AUTHKIT_ISSUER'), // e.g. http://localhost:3333/oidc
adapter: adapters.database({ connection: 'auth' }),
jwks: { source: 'managed', algorithm: 'RS256' },
ttl: { accessToken: '15m', refreshToken: '30d' },
accountStore: lucidAccountStore(AuthUser),
mountPath: '/oidc',
// React preset (after `node ace add --ui=react`). Use the `views` allowlist so screens
// added by future lib updates fall back to Edge views instead of crashing SSR.
// Remove the `render` key if you picked --ui=edge (built-in Edge views, zero config).
render: inertiaRenderer({
prefix: 'authkit',
views: [
'login', 'consent', 'signup', 'forgot', 'reset',
'verify-email', 'mfa-challenge',
'account/login', 'account/tokens', 'account/mfa',
],
}),
admin: { enabled: true },
adminApi: { enabled: true, apiKeys: [env.get('AUTHKIT_ADMIN_API_KEY')] },
})
export default authServerConfigissuer is the public provider URL and must end with mountPath — here …/oidc for
mountPath: '/oidc'. In this topology the issuer host is the host app's own origin.
No clients: block. Boot the server, then register the host app's own client in the
admin console at /admin/clients or via the Admin REST API (see A3a).
A3a. Register the host client
After the first boot, create the acme-web client (the host authenticating against
itself):
curl -X POST http://localhost:3333/api/authkit/v1/clients \
-H "Authorization: Bearer $AUTHKIT_ADMIN_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"clientId": "acme-web",
"redirectUris": ["http://localhost:3333/auth/callback"],
"postLogoutRedirectUris": ["http://localhost:3333/"],
"grantTypes": ["authorization_code", "refresh_token"],
"tokenEndpointAuthMethod": "client_secret_basic"
}'Save the clientSecret from the response — it is shown only once. Put it in .env:
AUTHKIT_CLIENT_ID=acme-web
AUTHKIT_CLIENT_SECRET=<secret from response>
AUTHKIT_REDIRECT_URI=http://localhost:3333/auth/callbackA4. Client config — point at your own issuer
import env from '#start/env'
import { defineConfig, resolvers } from '@adonis-agora/authkit-client'
import type { Identity } from '@adonis-agora/authkit-client'
import AppUser from '#models/app_user'
const authkitClientConfig = defineConfig({
issuer: env.get('AUTHKIT_ISSUER'), // same issuer — the host points at itself
clientId: env.get('AUTHKIT_CLIENT_ID'), // 'acme-web'
clientSecret: env.get('AUTHKIT_CLIENT_SECRET'),
redirectUri: env.get('AUTHKIT_REDIRECT_URI'),
resolver: resolvers.jwt({ tokenSource: 'session' }),
resolveUser: async (identity: Identity) => {
return AppUser.updateOrCreate(
{ id: identity.userId },
{ id: identity.userId, email: identity.email, fullName: identity.profile?.name ?? null }
)
},
})
export default authkitClientConfigA5. Mount the host + register middleware
import router from '@adonisjs/core/services/router'
import { registerAuthHost } from '@adonis-agora/authkit-server'
registerAuthHost(router, {
mountPath: '/oidc',
})One call mounts the provider, the login / consent / signup interaction, password reset, email verification, and the account console. See Host Kit for the render seam, branding, and ejecting.
Using @adonisjs/shield (default in the web starter kit)? Its CSRF protection blocks
POST {mountPath}/token unless you exempt it — see the callout in
Getting Started for the
authkitCsrfExceptions snippet.
router.use([
// ...
() => import('@adonis-agora/authkit-client/authkit_middleware'),
])The client middleware proactively refreshes the token set (see
Refresh Tokens) and attaches an authenticator to ctx.auth.
A6. The login / callback / logout flow
import {
generatePkce,
buildAuthorizeUrl,
exchangeCode,
buildEndSessionUrl,
} from '@adonis-agora/authkit-client'
const cfg = {
issuer: process.env.AUTHKIT_ISSUER!,
clientId: process.env.AUTHKIT_CLIENT_ID!,
clientSecret: process.env.AUTHKIT_CLIENT_SECRET!,
redirectUri: process.env.AUTHKIT_REDIRECT_URI!,
}
router.get('/login', async ({ session, response }) => {
const { verifier, challenge } = await generatePkce()
const state = crypto.randomUUID()
session.put('pkce', { verifier, state })
return response.redirect(
buildAuthorizeUrl({
...cfg,
scopes: ['openid', 'profile', 'email', 'offline_access'],
state,
codeChallenge: challenge,
})
)
})
router.get('/auth/callback', async ({ request, session, response }) => {
const stash = session.get('pkce') as { verifier: string; state: string }
const tokens = await exchangeCode({
...cfg,
code: request.input('code'),
codeVerifier: stash.verifier,
})
session.put('authkit', tokens) // the resolver reads the token set here
return response.redirect('/')
})
router.get('/logout', async ({ session, response }) => {
const tokens = session.get('authkit') as { idToken?: string } | undefined
session.clear()
return response.redirect(
buildEndSessionUrl({
issuer: cfg.issuer,
idToken: tokens?.idToken,
postLogoutRedirectUri: 'http://localhost:3333/',
})
)
})A7. Environment
AUTHKIT_ISSUER=http://localhost:3333/oidc
AUTHKIT_ADMIN_API_KEY=high-entropy-key-here
# set after registering the client (step A3a):
AUTHKIT_CLIENT_ID=acme-web
AUTHKIT_CLIENT_SECRET=<from admin API response>
AUTHKIT_REDIRECT_URI=http://localhost:3333/auth/callbackThat's the whole single-app setup — the provider and the relying party are the same process.
Topology B — separate apps
Three apps: a dedicated IdP (server kit only) and two pure client apps, acme-web and
acme-admin (client kit only), pointing at the IdP's issuer. There is no shared cookie or
session between them — auth flows entirely over OIDC redirects, and logout is RP-initiated (with
an optional back-channel pointer for server-to-server termination).
The IdP side
B1. Install & configure
npm install @adonis-agora/authkit-server
node ace configure @adonis-agora/authkit-serverB2. The account model
Identical to A2 — compose AuthUser from withAuthUser() and
withCredentials(). The IdP owns the user identities.
B3. Server config
import env from '#start/env'
import AuthUser from '#models/auth_user'
import {
defineConfig,
adapters,
lucidAccountStore,
inertiaRenderer,
} from '@adonis-agora/authkit-server'
const authServerConfig = defineConfig({
issuer: env.get('AUTHKIT_ISSUER'), // https://auth.acme.com/oidc
adapter: adapters.database({ connection: 'auth' }),
jwks: { source: 'managed', algorithm: 'RS256' },
ttl: { accessToken: '15m', refreshToken: '30d' },
accountStore: lucidAccountStore(AuthUser),
mountPath: '/oidc',
// React preset (after `node ace add --ui=react`). Remove this block and omit `render`
// to use the built-in Edge views instead (no pages needed in the project).
render: inertiaRenderer({
prefix: 'authkit',
views: [
'login', 'consent', 'signup', 'forgot', 'reset',
'verify-email', 'mfa-challenge',
'account/login', 'account/tokens', 'account/mfa',
],
}),
admin: { enabled: true },
adminApi: { enabled: true, apiKeys: [env.get('AUTHKIT_ADMIN_API_KEY')] },
})
export default authServerConfigB4. Mount the host
import router from '@adonisjs/core/services/router'
import { registerAuthHost } from '@adonis-agora/authkit-server'
registerAuthHost(router, {
mountPath: '/oidc',
adminApi: true,
})That single call is the entire IdP: provider endpoints, login / consent / signup, password reset, email verification, and the account console.
Using @adonisjs/shield on the IdP app? Exempt POST {mountPath}/token from CSRF — see
the authkitCsrfExceptions callout in
Getting Started.
AUTHKIT_ISSUER=https://auth.acme.com/oidc
AUTHKIT_ADMIN_API_KEY=high-entropy-key-hereB3a. Register the clients
After the first boot, register acme-web and acme-admin via the Admin REST API:
# acme-web
curl -X POST https://auth.acme.com/api/authkit/v1/clients \
-H "Authorization: Bearer $AUTHKIT_ADMIN_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"clientId": "acme-web",
"redirectUris": ["https://web.acme.com/auth/callback"],
"postLogoutRedirectUris": ["https://web.acme.com/"],
"grantTypes": ["authorization_code", "refresh_token"],
"tokenEndpointAuthMethod": "client_secret_basic"
}'
# acme-admin
curl -X POST https://auth.acme.com/api/authkit/v1/clients \
-H "Authorization: Bearer $AUTHKIT_ADMIN_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"clientId": "acme-admin",
"redirectUris": ["https://admin.acme.com/auth/callback"],
"postLogoutRedirectUris": ["https://admin.acme.com/"],
"grantTypes": ["authorization_code", "refresh_token"],
"tokenEndpointAuthMethod": "client_secret_basic"
}'Each response includes a clientSecret shown once — copy it into each client app's
.env. Back-channel logout URIs and other per-client settings can be edited in the admin
console at any time.
The client side (acme-web and acme-admin)
Each client app repeats the same steps with its own clientId, secret and redirect URI. The
example below is acme-web; acme-admin is identical with its own credentials.
B5. Install & configure
npm install @adonis-agora/authkit-client
node ace configure @adonis-agora/authkit-clientB6. Client config
import env from '#start/env'
import { defineConfig, resolvers } from '@adonis-agora/authkit-client'
import type { Identity } from '@adonis-agora/authkit-client'
import AppUser from '#models/app_user'
const authkitClientConfig = defineConfig({
issuer: env.get('AUTHKIT_ISSUER'), // the IdP's issuer — https://auth.acme.com/oidc
clientId: env.get('AUTHKIT_CLIENT_ID'), // 'acme-web'
clientSecret: env.get('AUTHKIT_CLIENT_SECRET'),
redirectUri: env.get('AUTHKIT_REDIRECT_URI'),
resolver: resolvers.jwt({ tokenSource: 'session' }),
resolveUser: async (identity: Identity) => {
return AppUser.updateOrCreate(
{ id: identity.userId },
{ id: identity.userId, email: identity.email, fullName: identity.profile?.name ?? null }
)
},
})
export default authkitClientConfigB7. Middleware
router.use([
// ...
() => import('@adonis-agora/authkit-client/authkit_middleware'),
])B8. Login / callback / logout
The flow is the same as A6, except the redirects cross
hosts — the browser bounces to https://auth.acme.com/oidc/auth and back to
https://web.acme.com/auth/callback. There is no shared session: the IdP's SSO cookie lives on
auth.acme.com, the app's session cookie lives on web.acme.com.
import {
generatePkce,
buildAuthorizeUrl,
exchangeCode,
buildEndSessionUrl,
} from '@adonis-agora/authkit-client'
const cfg = {
issuer: process.env.AUTHKIT_ISSUER!,
clientId: process.env.AUTHKIT_CLIENT_ID!,
clientSecret: process.env.AUTHKIT_CLIENT_SECRET!,
redirectUri: process.env.AUTHKIT_REDIRECT_URI!,
}
router.get('/login', async ({ session, response }) => {
const { verifier, challenge } = await generatePkce()
const state = crypto.randomUUID()
session.put('pkce', { verifier, state })
return response.redirect(
buildAuthorizeUrl({
...cfg,
scopes: ['openid', 'profile', 'email', 'offline_access'],
state,
codeChallenge: challenge,
})
)
})
router.get('/auth/callback', async ({ request, session, response }) => {
const stash = session.get('pkce') as { verifier: string; state: string }
const tokens = await exchangeCode({
...cfg,
code: request.input('code'),
codeVerifier: stash.verifier,
})
session.put('authkit', tokens)
return response.redirect('/')
})
// RP-initiated logout: clear the local session, then end the IdP session.
router.get('/logout', async ({ session, response }) => {
const tokens = session.get('authkit') as { idToken?: string } | undefined
session.clear()
return response.redirect(
buildEndSessionUrl({
issuer: cfg.issuer,
idToken: tokens?.idToken,
postLogoutRedirectUri: 'https://web.acme.com/',
})
)
})For server-initiated termination (logging a user out of every app at once), the IdP POSTs a signed
logout_token to each client's backchannelLogoutUri. Wire the receiving endpoint with
AuthkitClientManager.handleBackchannelLogout(ctx) — see
Back-channel Logout.
AUTHKIT_ISSUER=https://auth.acme.com/oidc
AUTHKIT_CLIENT_ID=acme-web
AUTHKIT_CLIENT_SECRET=web-super-secret
AUTHKIT_REDIRECT_URI=https://web.acme.com/auth/callbackacme-admin uses the same code with AUTHKIT_CLIENT_ID=acme-admin, its own secret, and
https://admin.acme.com/auth/callback.
Next steps
- Topologies — the conceptual standalone vs embedded reference.
- Customizing auth — recipes for route guards, role gating, user mapping, custom screens, emails, and events.
- MFA — TOTP and step-up.
- Admin Console — the built-in dashboard for users, roles, and clients.
- Device Flow —
urn:ietf:params:oauth:grant-type:device_code. - Resolvers —
jwt/pat/opaque. - Security — rate-limiting, lockout, audit, and logout.
- React — frontend
useAuth()and gating.